scene-tools.tsā¢26 kB
import { z } from 'zod';
import { LacyLightsGraphQLClient } from '../services/graphql-client-simple';
import { RAGService } from '../services/rag-service-simple';
import { AILightingService } from '../services/ai-lighting';
import { LightingDesignRequest } from '../types/lighting';
// Type definitions for better type safety
interface SceneActivationResult {
success: boolean;
message?: string;
error?: string;
}
interface FixtureInfo {
id: string;
name: string;
type: string;
}
interface FixtureValueInfo {
fixture: FixtureInfo;
channelValues: number[];
sceneOrder?: number;
}
interface SceneInfo {
name: string;
description: string | null;
fixtureValues: FixtureValueInfo[];
}
interface GenerateSceneResult {
sceneId?: string;
scene?: SceneInfo;
designReasoning?: string;
fixturesUsed?: number;
channelsSet?: number;
activation?: SceneActivationResult;
analysis?: any;
lightingCues?: any[];
totalCues?: number;
sceneTemplates?: any[];
characters?: any[];
overallMood?: string;
themes?: any[];
}
// Extended Scene interface to include optional project
interface SceneWithProject {
id: string;
name: string;
description: string | null;
fixtureValues: any[];
project?: {
id: string;
name: string;
};
}
const GenerateSceneSchema = z.object({
projectId: z.string(),
sceneDescription: z.string(),
scriptContext: z.string().optional(),
sceneType: z.enum(['full', 'additive']).default('full'),
designPreferences: z.object({
colorPalette: z.array(z.string()).optional(),
mood: z.string().optional(),
intensity: z.enum(['subtle', 'moderate', 'dramatic']).optional(),
focusAreas: z.array(z.string()).optional()
}).optional(),
fixtureFilter: z.object({
includeTypes: z.array(z.enum(['LED_PAR', 'MOVING_HEAD', 'STROBE', 'DIMMER', 'OTHER'])).optional(),
excludeTypes: z.array(z.enum(['LED_PAR', 'MOVING_HEAD', 'STROBE', 'DIMMER', 'OTHER'])).optional(),
includeTags: z.array(z.string()).optional()
}).optional(),
activate: z.boolean().optional()
});
const AnalyzeScriptSchema = z.object({
scriptText: z.string(),
extractLightingCues: z.boolean().default(true),
suggestScenes: z.boolean().default(true)
});
const OptimizeSceneSchema = z.object({
sceneId: z.string(),
projectId: z.string(),
optimizationGoals: z.array(z.enum(['energy_efficiency', 'color_accuracy', 'dramatic_impact', 'technical_simplicity'])).default(['dramatic_impact'])
});
const UpdateSceneSchema = z.object({
sceneId: z.string(),
name: z.string().optional(),
description: z.string().optional(),
fixtureValues: z.array(z.object({
fixtureId: z.string(),
channelValues: z.array(z.number().min(0).max(255))
})).optional()
});
const ActivateSceneSchema = z.object({
projectId: z.string().optional(),
sceneId: z.string().optional(),
sceneName: z.string().optional()
});
const FadeToBlackSchema = z.object({
fadeOutTime: z.number().default(3.0)
});
// š”ļø SAFE SCENE UPDATE SCHEMAS
const AddFixturesToSceneSchema = z.object({
sceneId: z.string(),
fixtureValues: z.array(z.object({
fixtureId: z.string(),
channelValues: z.array(z.number().min(0).max(255)),
sceneOrder: z.number().optional()
})),
overwriteExisting: z.boolean().default(false)
});
const RemoveFixturesFromSceneSchema = z.object({
sceneId: z.string(),
fixtureIds: z.array(z.string())
});
const GetSceneFixtureValuesSchema = z.object({
sceneId: z.string(),
includeFixtureDetails: z.boolean().default(true)
});
const EnsureFixturesInSceneSchema = z.object({
sceneId: z.string(),
fixtureValues: z.array(z.object({
fixtureId: z.string(),
channelValues: z.array(z.number().min(0).max(255)),
sceneOrder: z.number().optional()
}))
});
const UpdateScenePartialSchema = z.object({
sceneId: z.string(),
name: z.string().optional(),
description: z.string().optional(),
fixtureValues: z.array(z.object({
fixtureId: z.string(),
channelValues: z.array(z.number().min(0).max(255)),
sceneOrder: z.number().optional()
})).optional(),
mergeFixtures: z.boolean().default(true)
});
export class SceneTools {
constructor(
private graphqlClient: LacyLightsGraphQLClient,
private ragService: RAGService,
private aiLightingService: AILightingService
) {}
async generateScene(args: z.infer<typeof GenerateSceneSchema>) {
const {
projectId,
sceneDescription,
scriptContext,
sceneType,
designPreferences,
fixtureFilter,
activate
} = GenerateSceneSchema.parse(args);
try {
// Get project and available fixtures
const project = await this.graphqlClient.getProject(projectId);
if (!project) {
throw new Error(`Project with ID ${projectId} not found`);
}
// Get all fixtures for context, then filter based on scene type and criteria
let availableFixtures = project.fixtures;
const allFixtures = project.fixtures; // Keep reference to all fixtures
// For additive scenes, we need fixtureFilter to specify which fixtures to modify
if (sceneType === 'additive' && !fixtureFilter) {
throw new Error('Additive scenes require fixtureFilter to specify which fixtures to modify');
}
if (fixtureFilter) {
if (fixtureFilter.includeTypes) {
availableFixtures = availableFixtures.filter(f =>
f.type && fixtureFilter.includeTypes!.includes(f.type)
);
}
if (fixtureFilter.excludeTypes) {
availableFixtures = availableFixtures.filter(f =>
f.type && !fixtureFilter.excludeTypes!.includes(f.type)
);
}
if (fixtureFilter.includeTags) {
availableFixtures = availableFixtures.filter(f =>
fixtureFilter.includeTags!.some(tag => f.tags.includes(tag))
);
}
}
if (availableFixtures.length === 0) {
throw new Error('No fixtures available matching the specified criteria');
}
// Create lighting design request
const lightingRequest: LightingDesignRequest = {
scriptContext: scriptContext || sceneDescription,
sceneDescription,
availableFixtures,
sceneType,
allFixtures: sceneType === 'additive' ? allFixtures : undefined,
designPreferences
};
// Generate scene using AI
const generatedScene = await this.aiLightingService.generateScene(lightingRequest);
// Optimize the scene for available fixtures
const optimizedScene = await this.aiLightingService.optimizeSceneForFixtures(
generatedScene,
availableFixtures
);
// Create the scene in the database
const createdScene = await this.graphqlClient.createScene({
name: optimizedScene.name,
description: optimizedScene.description,
projectId,
fixtureValues: optimizedScene.fixtureValues
});
const result: GenerateSceneResult = {
sceneId: createdScene.id,
scene: {
name: createdScene.name,
description: createdScene.description || null,
fixtureValues: createdScene.fixtureValues.map(fv => ({
fixture: {
id: fv.fixture.id,
name: fv.fixture.name,
type: fv.fixture.type || 'UNKNOWN'
},
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder
}))
},
designReasoning: optimizedScene.reasoning,
fixturesUsed: availableFixtures.length,
channelsSet: optimizedScene.fixtureValues.reduce((total, fv) => total + (fv.channelValues?.length || 0), 0)
};
// Activate the scene if requested
if (activate) {
try {
const success = await this.graphqlClient.setSceneLive(createdScene.id);
result.activation = {
success,
message: success
? `Scene "${createdScene.name}" is now active`
: 'Scene created but activation failed'
};
} catch (activationError) {
// Include activation error but don't fail the entire operation
result.activation = {
success: false,
error: `Scene created but activation failed: ${activationError}`
};
}
}
return result;
} catch (error) {
throw new Error(`Failed to generate scene: ${error}`);
}
}
async analyzeScript(args: z.infer<typeof AnalyzeScriptSchema>) {
const { scriptText, extractLightingCues, suggestScenes } = AnalyzeScriptSchema.parse(args);
try {
// Analyze script using RAG service
const scriptAnalysis = await this.ragService.analyzeScript(scriptText);
const result: GenerateSceneResult = {
analysis: scriptAnalysis,
totalCues: scriptAnalysis.scenes.length,
characters: scriptAnalysis.characters,
overallMood: scriptAnalysis.overallMood,
themes: scriptAnalysis.themes
};
if (extractLightingCues) {
// Extract specific lighting cues from the analysis
const lightingCues = scriptAnalysis.scenes.flatMap((scene, _index) =>
scene.lightingCues.map(cue => ({
sceneNumber: scene.sceneNumber,
cue,
context: scene.content.substring(0, 200) + '...',
suggestedTiming: this.suggestCueTiming(cue, scene.mood)
}))
);
result.lightingCues = lightingCues;
result.totalCues = lightingCues.length;
}
if (suggestScenes) {
// Generate scene suggestions based on script analysis
const sceneTemplates = await Promise.all(
scriptAnalysis.scenes.slice(0, 5).map(async (scene, _index) => {
const recommendations = await this.ragService.generateLightingRecommendations(
scene.content,
scene.mood,
['LED_PAR', 'MOVING_HEAD'] // Default fixture types
);
return {
sceneNumber: scene.sceneNumber,
title: scene.title || `Scene ${scene.sceneNumber}`,
mood: scene.mood,
timeOfDay: scene.timeOfDay,
location: scene.location,
suggestedLighting: {
colorPalette: recommendations.colorSuggestions,
intensity: this.mapIntensityLevel(recommendations.intensityLevels),
focusAreas: recommendations.focusAreas,
reasoning: recommendations.reasoning
},
estimatedFixtureCount: this.estimateFixtureNeeds(recommendations)
};
})
);
result.sceneTemplates = sceneTemplates;
}
return result;
} catch (error) {
throw new Error(`Failed to analyze script: ${error}`);
}
}
async optimizeScene(args: z.infer<typeof OptimizeSceneSchema>) {
const { sceneId, projectId, optimizationGoals } = OptimizeSceneSchema.parse(args);
try {
// Get the current scene
const project = await this.graphqlClient.getProject(projectId);
if (!project) {
throw new Error(`Project with ID ${projectId} not found`);
}
const scene = project.scenes.find(s => s.id === sceneId);
if (!scene) {
throw new Error(`Scene with ID ${sceneId} not found`);
}
// Scene optimization is planned for future implementation
// Currently returns analysis and recommendations without modifying the scene
return {
sceneId,
sceneName: scene.name,
originalFixtureCount: scene.fixtureValues.length,
requestedGoals: optimizationGoals,
status: 'not_implemented',
message: 'Scene optimization is planned for future releases. Current implementation provides recommendations only.',
recommendations: this.getOptimizationRecommendations(optimizationGoals),
};
} catch (error) {
throw new Error(`Failed to optimize scene: ${error}`);
}
}
async updateScene(args: z.infer<typeof UpdateSceneSchema>) {
const { sceneId, name, description, fixtureValues } = UpdateSceneSchema.parse(args);
try {
// Build the update input object with only provided fields
const updateInput: any = {};
if (name !== undefined) updateInput.name = name;
if (description !== undefined) updateInput.description = description;
if (fixtureValues !== undefined) updateInput.fixtureValues = fixtureValues;
// Update the scene
const updatedScene = await this.graphqlClient.updateScene(sceneId, updateInput);
return {
sceneId: updatedScene.id,
scene: {
name: updatedScene.name,
description: updatedScene.description,
updatedAt: updatedScene.updatedAt,
fixtureValues: updatedScene.fixtureValues.map(fv => ({
fixture: {
id: fv.fixture.id,
name: fv.fixture.name
},
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder
}))
},
fixturesUpdated: fixtureValues ? fixtureValues.length : 0,
channelsUpdated: fixtureValues ? fixtureValues.reduce((total, fv) => total + (fv.channelValues?.length || 0), 0) : 0
};
} catch (error) {
throw new Error(`Failed to update scene: ${error}`);
}
}
private suggestCueTiming(cue: string, mood: string): string {
// Basic heuristics for cue timing based on content and mood
const cueWords = cue.toLowerCase();
if (cueWords.includes('blackout') || cueWords.includes('lights out')) {
return 'Fast (1-2 seconds)';
}
if (cueWords.includes('fade') || cueWords.includes('dim')) {
return mood === 'tense' ? 'Medium (3-5 seconds)' : 'Slow (5-8 seconds)';
}
if (cueWords.includes('snap') || cueWords.includes('flash')) {
return 'Instant (0 seconds)';
}
return 'Medium (3-5 seconds)';
}
private mapIntensityLevel(intensityLevels: Record<string, number>): string {
const avgIntensity = Object.values(intensityLevels).reduce((a, b) => a + b, 0) / Object.values(intensityLevels).length;
if (avgIntensity < 30) return 'subtle';
if (avgIntensity < 70) return 'moderate';
return 'dramatic';
}
private estimateFixtureNeeds(recommendations: any): number {
let baseCount = 4; // Minimum for basic coverage
if (recommendations.focusAreas?.length > 2) baseCount += 2;
if (recommendations.colorSuggestions?.length > 3) baseCount += 2;
if (recommendations.intensityLevels?.key > 80) baseCount += 1;
return Math.min(baseCount, 12); // Cap at reasonable maximum
}
/**
* Provides general recommendations for scene optimization goals
*/
private getOptimizationRecommendations(goals: string[]): string[] {
const recommendations: string[] = [];
for (const goal of goals) {
switch (goal) {
case 'energy_efficiency':
recommendations.push(
'Use fewer fixtures at higher intensity rather than many at low intensity',
'Prioritize LED fixtures over traditional tungsten',
'Consider consolidating similar color washes'
);
break;
case 'color_accuracy':
recommendations.push(
'Use fixtures with dedicated white channels',
'Avoid oversaturated colors that may appear unnatural',
'Consider color temperature consistency across fixtures'
);
break;
case 'dramatic_impact':
recommendations.push(
'Use moving heads for dynamic positioning',
'Create strong key light with softer fill',
'Consider backlight for separation and depth'
);
break;
case 'technical_simplicity':
recommendations.push(
'Group similar fixtures for easier control',
'Use preset colors rather than custom mixes',
'Minimize moving head positioning changes'
);
break;
}
}
return recommendations;
}
async activateScene(args: z.infer<typeof ActivateSceneSchema>) {
const { projectId, sceneId, sceneName } = ActivateSceneSchema.parse(args);
try {
let resolvedSceneId = sceneId;
// If scene name is provided, find the scene by name
if (sceneName) {
if (!projectId) {
// Get all projects to search for the scene
const projects = await this.graphqlClient.getProjects();
// Search for a scene with this name across all projects
for (const project of projects) {
const scene = project.scenes.find(s =>
s.name.toLowerCase() === sceneName.toLowerCase() ||
s.name.toLowerCase().includes(sceneName.toLowerCase())
);
if (scene) {
resolvedSceneId = scene.id;
break;
}
}
if (!resolvedSceneId) {
throw new Error(`Scene with name "${sceneName}" not found in any project`);
}
} else {
// Search in specific project
const project = await this.graphqlClient.getProject(projectId);
if (!project) {
throw new Error(`Project with ID ${projectId} not found`);
}
const scene = project.scenes.find(s =>
s.name.toLowerCase() === sceneName.toLowerCase() ||
s.name.toLowerCase().includes(sceneName.toLowerCase())
);
if (!scene) {
throw new Error(`Scene with name "${sceneName}" not found in project ${project.name}`);
}
resolvedSceneId = scene.id;
}
} else if (!sceneId) {
throw new Error('Either sceneId or sceneName must be provided');
}
// Ensure resolvedSceneId is defined before using it
if (!resolvedSceneId) {
throw new Error('Resolved scene ID is undefined');
}
// Activate the scene
const success = await this.graphqlClient.setSceneLive(resolvedSceneId);
if (!success) {
throw new Error('Failed to activate scene');
}
// Get scene details for response
const scene = await this.graphqlClient.getScene(resolvedSceneId);
if (!scene) {
throw new Error('Scene could not be retrieved after activation');
}
return {
success: true,
scene: {
id: scene.id,
name: scene.name,
description: scene.description
},
message: `Scene "${scene.name}" is now active`,
fixturesActive: scene.fixtureValues.length,
hint: 'Use fade_to_black to turn off all lights'
};
} catch (error) {
throw new Error(`Failed to activate scene: ${error}`);
}
}
async fadeToBlack(args: z.infer<typeof FadeToBlackSchema>) {
const { fadeOutTime } = FadeToBlackSchema.parse(args);
try {
const success = await this.graphqlClient.fadeToBlack(fadeOutTime);
return {
success,
message: success
? `Lights faded to black over ${fadeOutTime} seconds`
: 'Failed to fade to black',
fadeOutTime
};
} catch (error) {
throw new Error(`Failed to fade to black: ${error}`);
}
}
async getCurrentActiveScene() {
try {
const activeScene = await this.graphqlClient.getCurrentActiveScene();
if (!activeScene) {
return {
hasActiveScene: false,
message: 'No scene is currently active'
};
}
const sceneWithProject = activeScene as SceneWithProject;
return {
hasActiveScene: true,
scene: {
id: sceneWithProject.id,
name: sceneWithProject.name,
description: sceneWithProject.description,
project: sceneWithProject.project ? {
id: sceneWithProject.project.id,
name: sceneWithProject.project.name
} : null
},
fixturesActive: sceneWithProject.fixtureValues.length,
message: `Scene "${sceneWithProject.name}" is currently active`
};
} catch (error) {
throw new Error(`Failed to get current active scene: ${error}`);
}
}
// š”ļø SAFE SCENE UPDATE METHODS
async addFixturesToScene(args: z.infer<typeof AddFixturesToSceneSchema>) {
const { sceneId, fixtureValues, overwriteExisting } = AddFixturesToSceneSchema.parse(args);
try {
const updatedScene = await this.graphqlClient.addFixturesToScene(
sceneId,
fixtureValues,
overwriteExisting
);
return {
sceneId: updatedScene.id,
scene: {
name: updatedScene.name,
description: updatedScene.description,
updatedAt: updatedScene.updatedAt,
totalFixtures: updatedScene.fixtureValues.length,
fixtureValues: updatedScene.fixtureValues.map(fv => ({
fixture: {
id: fv.fixture.id,
name: fv.fixture.name
},
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder
}))
},
fixturesAdded: fixtureValues.length,
overwriteMode: overwriteExisting,
message: `Successfully added ${fixtureValues.length} fixtures to scene "${updatedScene.name}"${overwriteExisting ? ' (overwriting existing)' : ' (preserving existing)'}`
};
} catch (error) {
throw new Error(`Failed to add fixtures to scene: ${error}`);
}
}
async removeFixturesFromScene(args: z.infer<typeof RemoveFixturesFromSceneSchema>) {
const { sceneId, fixtureIds } = RemoveFixturesFromSceneSchema.parse(args);
try {
const updatedScene = await this.graphqlClient.removeFixturesFromScene(sceneId, fixtureIds);
return {
sceneId: updatedScene.id,
scene: {
name: updatedScene.name,
description: updatedScene.description,
updatedAt: updatedScene.updatedAt,
totalFixtures: updatedScene.fixtureValues.length,
fixtureValues: updatedScene.fixtureValues.map(fv => ({
fixture: {
id: fv.fixture.id,
name: fv.fixture.name
},
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder
}))
},
fixturesRemoved: fixtureIds.length,
message: `Successfully removed ${fixtureIds.length} fixtures from scene "${updatedScene.name}"`
};
} catch (error) {
throw new Error(`Failed to remove fixtures from scene: ${error}`);
}
}
async getSceneFixtureValues(args: z.infer<typeof GetSceneFixtureValuesSchema>) {
const { sceneId, includeFixtureDetails } = GetSceneFixtureValuesSchema.parse(args);
try {
const scene = await this.graphqlClient.getScene(sceneId);
if (!scene) {
throw new Error(`Scene with ID ${sceneId} not found`);
}
return {
sceneId: scene.id,
scene: {
name: scene.name,
description: scene.description,
totalFixtures: scene.fixtureValues.length
},
fixtureValues: scene.fixtureValues.map(fv => ({
fixtureId: fv.fixture.id,
fixtureName: includeFixtureDetails ? fv.fixture.name : undefined,
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder,
channelCount: fv.channelValues.length
})),
message: `Retrieved fixture values for ${scene.fixtureValues.length} fixtures in scene "${scene.name}"`
};
} catch (error) {
throw new Error(`Failed to get scene fixture values: ${error}`);
}
}
async ensureFixturesInScene(args: z.infer<typeof EnsureFixturesInSceneSchema>) {
const { sceneId, fixtureValues } = EnsureFixturesInSceneSchema.parse(args);
try {
// Use addFixturesToScene with overwriteExisting: false for safe behavior
const updatedScene = await this.graphqlClient.addFixturesToScene(
sceneId,
fixtureValues,
false // Don't overwrite existing
);
return {
sceneId: updatedScene.id,
scene: {
name: updatedScene.name,
description: updatedScene.description,
totalFixtures: updatedScene.fixtureValues.length
},
fixturesAdded: fixtureValues.length,
message: `Added ${fixtureValues.length} fixtures to scene "${updatedScene.name}" (only if missing)`
};
} catch (error) {
throw new Error(`Failed to ensure fixtures in scene: ${error}`);
}
}
async updateScenePartial(args: z.infer<typeof UpdateScenePartialSchema>) {
const { sceneId, name, description, fixtureValues, mergeFixtures } = UpdateScenePartialSchema.parse(args);
try {
const updatedScene = await this.graphqlClient.updateScenePartial(sceneId, {
name,
description,
fixtureValues,
mergeFixtures
});
return {
sceneId: updatedScene.id,
scene: {
name: updatedScene.name,
description: updatedScene.description,
updatedAt: updatedScene.updatedAt,
totalFixtures: updatedScene.fixtureValues.length,
fixtureValues: updatedScene.fixtureValues.map(fv => ({
fixture: {
id: fv.fixture.id,
name: fv.fixture.name
},
channelValues: fv.channelValues,
sceneOrder: fv.sceneOrder
}))
},
updateType: mergeFixtures ? 'merged' : 'replaced',
fixturesUpdated: fixtureValues ? fixtureValues.length : 0,
message: `Successfully updated scene "${updatedScene.name}"${mergeFixtures ? ' (safe merge)' : ' (full replace)'}`
};
} catch (error) {
throw new Error(`Failed to update scene partially: ${error}`);
}
}
}