Skip to main content
Glama

LacyLights MCP Server

by bbernstein
cue-tools.ts56.2 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 { GeneratedScene } from "../types/lighting"; const CreateCueSequenceSchema = z.object({ projectId: z.string(), scriptContext: z.string(), sceneIds: z.array(z.string()), sequenceName: z.string(), transitionPreferences: z .object({ defaultFadeIn: z.number().default(3), defaultFadeOut: z.number().default(3), followCues: z.boolean().default(false), autoAdvance: z.boolean().default(false), }) .optional(), }); const GenerateActCuesSchema = z.object({ projectId: z.string(), actNumber: z.number(), scriptText: z.string(), existingScenes: z.array(z.string()).optional(), cueListName: z.string().optional(), }); const OptimizeCueTimingSchema = z.object({ cueListId: z.string(), projectId: z.string(), optimizationStrategy: z .enum([ "smooth_transitions", "dramatic_timing", "technical_precision", "energy_conscious", ]) .default("smooth_transitions"), }); const AnalyzeCueStructureSchema = z.object({ cueListId: z.string(), projectId: z.string(), includeRecommendations: z.boolean().default(true), }); // Playback control schemas const StartCueListSchema = z.object({ cueListId: z.string().optional(), cueListName: z.string().optional(), projectId: z.string().optional(), startFromCue: z.number().optional(), }); const NextCueSchema = z.object({ fadeInTime: z.number().optional(), }); const PreviousCueSchema = z.object({ fadeInTime: z.number().optional(), }); const GoToCueSchema = z.object({ cueNumber: z.number().optional(), cueName: z.string().optional(), fadeInTime: z.number().optional(), }); const _StopCueListSchema = z.object({}); const _GetCueListStatusSchema = z.object({}); const BulkUpdateCuesSchema = z.object({ cueIds: z.array(z.string()), fadeInTime: z.number().optional(), fadeOutTime: z.number().optional(), followTime: z.number().nullable().optional(), easingType: z.string().optional(), }); // Type for bulk update data interface BulkUpdateData { fadeInTime?: number; fadeOutTime?: number; followTime?: number | null; easingType?: string; } // Type for cue response from GraphQL interface CueResponse { id: string; name: string; cueNumber: number; scene: { name: string; }; fadeInTime: number; fadeOutTime: number; followTime?: number | null; notes?: string; } // Removed CueListPlaybackState interface - now using backend's centralized state export class CueTools { // Cache for scene-to-cue-list mapping to avoid expensive nested loops private sceneToCueListCache: Map< string, { cueListId: string; cueId: string; expiresAt: number } > = new Map(); private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes constructor( private graphqlClient: LacyLightsGraphQLClient, private ragService: RAGService, private aiLightingService: AILightingService, ) {} /** * Helper method to get the currently playing cue list ID from backend * @throws Error if no cue list is currently playing * @returns The ID of the currently playing cue list */ private async getActiveCueListId(): Promise<string> { const projects = await this.graphqlClient.getProjects(); for (const project of projects) { for (const cueList of project.cueLists) { const status = await this.graphqlClient.getCueListPlaybackStatus( cueList.id, ); if (status && status.isPlaying) { return cueList.id; } } } throw new Error( "No cue list is currently playing. Use start_cue_list first.", ); } async createCueSequence(args: z.infer<typeof CreateCueSequenceSchema>) { const { projectId, scriptContext, sceneIds, sequenceName, transitionPreferences, } = CreateCueSequenceSchema.parse(args); try { // Get project and validate scenes const project = await this.graphqlClient.getProject(projectId); if (!project) { throw new Error(`Project with ID ${projectId} not found`); } // Map scenes in the exact order of sceneIds to maintain consistency const scenes = sceneIds.map((sceneId) => { const scene = project.scenes.find((s) => s.id === sceneId); if (!scene) { throw new Error(`Scene with ID ${sceneId} not found in the project`); } return scene; }); // Convert scenes to GeneratedScene format for AI processing const generatedScenes: GeneratedScene[] = scenes.map((scene) => ({ name: scene.name, description: scene.description || "", fixtureValues: scene.fixtureValues.map((fv) => ({ fixtureId: fv.fixture.id, channelValues: fv.channelValues, // Already a number array })), reasoning: `Existing scene: ${scene.name}`, })); // Generate cue sequence using AI const cueSequence = await this.aiLightingService.generateCueSequence( scriptContext, generatedScenes, transitionPreferences, ); // Create the cue list in the database const cueList = await this.graphqlClient.createCueList({ name: sequenceName, description: cueSequence.description, projectId, }); // Create individual cues const createdCues = []; for (let i = 0; i < cueSequence.cues.length; i++) { const cueData = cueSequence.cues[i]; // The AI returns sceneId as a string that might be an index or scene reference // Try to parse it as an index first let sceneId: string; const sceneIdAsNumber = parseInt(cueData.sceneId); if ( !isNaN(sceneIdAsNumber) && sceneIdAsNumber >= 0 && sceneIdAsNumber < sceneIds.length ) { // It's a valid index, use it sceneId = sceneIds[sceneIdAsNumber]; } else { // Try to find it in the sceneIds array const sceneIndex = sceneIds.findIndex((id) => id === cueData.sceneId); sceneId = sceneIndex >= 0 ? sceneIds[sceneIndex] : sceneIds[Math.min(i, sceneIds.length - 1)]; // Fallback to corresponding index or last scene } const cue = await this.graphqlClient.createCue({ name: cueData.name, cueNumber: cueData.cueNumber, cueListId: cueList.id, sceneId: sceneId, fadeInTime: cueData.fadeInTime, fadeOutTime: cueData.fadeOutTime, followTime: cueData.followTime, notes: cueData.notes, }); createdCues.push(cue); } return { cueListId: cueList.id, cueList: { name: cueList.name, description: cueList.description, totalCues: createdCues.length, }, cues: createdCues.map((cue) => ({ id: cue.id, name: cue.name, cueNumber: cue.cueNumber, fadeInTime: cue.fadeInTime, fadeOutTime: cue.fadeOutTime, followTime: cue.followTime, notes: cue.notes, sceneName: cue.scene.name, })), sequenceReasoning: cueSequence.reasoning, statistics: { totalCues: createdCues.length, averageFadeTime: createdCues.reduce((sum, cue) => sum + cue.fadeInTime, 0) / createdCues.length, followCues: createdCues.filter((cue) => cue.followTime !== null) .length, estimatedDuration: this.estimateSequenceDuration(createdCues), }, }; } catch (error) { throw new Error(`Failed to create cue sequence: ${error}`); } } async generateActCues(args: z.infer<typeof GenerateActCuesSchema>) { const { projectId: _projectId, actNumber, scriptText, existingScenes: _existingScenes, cueListName, } = GenerateActCuesSchema.parse(args); try { // Analyze the script for this act const scriptAnalysis = await this.ragService.analyzeScript(scriptText); // Filter scenes for this act (assuming scene numbers indicate acts) const actScenes = scriptAnalysis.scenes.filter((scene) => { const sceneNum = parseFloat(scene.sceneNumber); return Math.floor(sceneNum) === actNumber; }); if (actScenes.length === 0) { throw new Error( `No scenes found for Act ${actNumber} in the provided script`, ); } // Generate cue suggestions for each scene in the act const cueTemplates = await Promise.all( actScenes.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, cueName: `Cue ${scene.sceneNumber}`, description: scene.title || `Scene ${scene.sceneNumber}`, mood: scene.mood, timeOfDay: scene.timeOfDay, location: scene.location, lightingCues: scene.lightingCues, suggestedTiming: { fadeIn: this.calculateFadeTime(scene.mood, "in"), fadeOut: this.calculateFadeTime(scene.mood, "out"), autoFollow: scene.lightingCues.some( (cue) => cue.toLowerCase().includes("auto") || cue.toLowerCase().includes("follow"), ), }, colorSuggestions: recommendations.colorSuggestions, intensityLevel: recommendations.intensityLevels, }; }), ); return { actNumber, totalScenes: actScenes.length, suggestedCueListName: cueListName || `Act ${actNumber} Cues`, cueTemplates, actAnalysis: { overallMood: this.determineActMood(actScenes), keyMoments: this.identifyKeyMoments(actScenes), transitionTypes: this.analyzeTransitions(actScenes), estimatedDuration: this.estimateActDuration(cueTemplates), }, recommendations: { preShowChecks: this.generatePreShowChecklist(cueTemplates), criticalCues: this.identifyCriticalCues(cueTemplates), backupPlans: this.suggestBackupPlans(cueTemplates), }, }; } catch (error) { throw new Error(`Failed to generate act cues: ${error}`); } } async optimizeCueTiming(args: z.infer<typeof OptimizeCueTimingSchema>) { const { cueListId, projectId, optimizationStrategy } = OptimizeCueTimingSchema.parse(args); try { const project = await this.graphqlClient.getProject(projectId); if (!project) { throw new Error(`Project with ID ${projectId} not found`); } const cueList = project.cueLists.find((cl) => cl.id === cueListId); if (!cueList) { throw new Error(`Cue list with ID ${cueListId} not found`); } const optimizations = this.generateTimingOptimizations( cueList.cues, optimizationStrategy, ); return { cueListId, strategy: optimizationStrategy, originalTiming: { totalCues: cueList.cues.length, averageFadeIn: this.calculateAverageFadeTime( cueList.cues, "fadeInTime", ), averageFadeOut: this.calculateAverageFadeTime( cueList.cues, "fadeOutTime", ), followCues: cueList.cues.filter((cue) => cue.followTime !== undefined) .length, }, optimizedTiming: optimizations.newTiming, changes: optimizations.changes, reasoning: optimizations.reasoning, estimatedImprovement: optimizations.improvement, }; } catch (error) { throw new Error(`Failed to optimize cue timing: ${error}`); } } async analyzeCueStructure(args: z.infer<typeof AnalyzeCueStructureSchema>) { const { cueListId, projectId, includeRecommendations } = AnalyzeCueStructureSchema.parse(args); try { const project = await this.graphqlClient.getProject(projectId); if (!project) { throw new Error(`Project with ID ${projectId} not found`); } const cueList = project.cueLists.find((cl) => cl.id === cueListId); if (!cueList) { throw new Error(`Cue list with ID ${cueListId} not found`); } const analysis = { cueListId, name: cueList.name, structure: { totalCues: cueList.cues.length, cueNumbering: this.analyzeCueNumbering(cueList.cues), fadeTimings: this.analyzeFadeTimings(cueList.cues), sceneUsage: this.analyzeSceneUsage(cueList.cues, project.scenes), followStructure: this.analyzeFollowStructure(cueList.cues), }, patterns: { commonFadeTimes: this.findCommonFadeTimes(cueList.cues), timingPatterns: this.identifyTimingPatterns(cueList.cues), sceneTransitions: this.analyzeSceneTransitions(cueList.cues), }, potentialIssues: this.identifyPotentialIssues(cueList.cues), statistics: { estimatedRuntime: this.estimateSequenceDuration(cueList.cues), manualCues: cueList.cues.filter((cue) => !cue.followTime).length, autoCues: cueList.cues.filter((cue) => cue.followTime).length, averageCueSpacing: this.calculateAverageCueSpacing(cueList.cues), }, }; if (includeRecommendations) { (analysis as any).recommendations = { numbering: this.recommendNumberingImprovements(cueList.cues), timing: this.recommendTimingImprovements(cueList.cues), structure: this.recommendStructureImprovements(cueList.cues), safety: this.recommendSafetyConsiderations(cueList.cues), }; } return analysis; } catch (error) { throw new Error(`Failed to analyze cue structure: ${error}`); } } private estimateSequenceDuration(cues: any[]): number { // Estimate total runtime in seconds let totalTime = 0; for (const cue of cues) { totalTime += cue.fadeInTime; if (cue.followTime) { totalTime += cue.followTime; } else { totalTime += 5; // Assume 5 seconds for manual advance } } return totalTime; } private calculateFadeTime(mood: string, direction: "in" | "out"): number { const baseTimes = { tense: { in: 1, out: 2 }, romantic: { in: 5, out: 8 }, dramatic: { in: 3, out: 5 }, cheerful: { in: 2, out: 3 }, mysterious: { in: 6, out: 4 }, default: { in: 3, out: 3 }, }; return ( baseTimes[mood as keyof typeof baseTimes]?.[direction] || baseTimes.default[direction] ); } private determineActMood(scenes: any[]): string { const moods = scenes.map((s) => s.mood); // Return the most common mood, or 'mixed' if no clear pattern const moodCounts = moods.reduce( (acc, mood) => { acc[mood] = (acc[mood] || 0) + 1; return acc; }, {} as Record<string, number>, ); const dominantMood = Object.entries(moodCounts).sort( ([, a], [, b]) => (b as number) - (a as number), )[0]; return dominantMood && (dominantMood[1] as number) > scenes.length / 2 ? dominantMood[0] : "mixed"; } private identifyKeyMoments(scenes: any[]): string[] { return scenes .filter( (scene) => scene.lightingCues.length > 0 || ["dramatic", "climactic", "tense"].includes(scene.mood), ) .map( (scene) => `Scene ${scene.sceneNumber}: ${scene.title || scene.mood}`, ); } private analyzeTransitions(scenes: any[]): string[] { const transitions = []; for (let i = 0; i < scenes.length - 1; i++) { const current = scenes[i]; const next = scenes[i + 1]; if (current.mood !== next.mood) { transitions.push(`${current.mood} → ${next.mood}`); } } return transitions; } private estimateActDuration(cueTemplates: any[]): number { return cueTemplates.reduce((total, template) => { return total + template.suggestedTiming.fadeIn + 60; // Assume 1 minute per scene minimum }, 0); } private generatePreShowChecklist(_cueTemplates: any[]): string[] { return [ "Test all moving head positions", "Verify color mixing on LED fixtures", "Check fade engine calibration", "Confirm backup lighting positions", "Test emergency blackout procedures", ]; } private identifyCriticalCues(cueTemplates: any[]): string[] { return cueTemplates .filter( (template) => template.mood === "dramatic" || template.lightingCues.length > 2, ) .map( (template) => `Cue ${template.sceneNumber}: ${template.description}`, ); } private suggestBackupPlans(_cueTemplates: any[]): string[] { return [ "Manual override available for all automated cues", "Simplified lighting states for technical failures", "Alternative fixtures identified for primary positions", "Emergency work lights accessible", ]; } private generateTimingOptimizations(cues: any[], strategy: string) { // Simplified optimization logic - in practice this would be more sophisticated const changes = []; let improvement = ""; switch (strategy) { case "smooth_transitions": changes.push("Standardized fade times for consistency"); changes.push("Added buffer time between manual cues"); improvement = "Smoother visual transitions"; break; case "dramatic_timing": changes.push("Shortened fade times for dramatic moments"); changes.push("Added follow cues for automatic sequences"); improvement = "Enhanced dramatic impact"; break; case "technical_precision": changes.push("Standardized cue numbering increments"); changes.push("Consistent fade time patterns"); improvement = "Easier operation and fewer mistakes"; break; case "energy_conscious": changes.push("Longer fade times to reduce power spikes"); changes.push("Staggered fixture activation"); improvement = "Reduced power consumption peaks"; break; } return { newTiming: { /* optimized timing values */ }, changes, reasoning: `Applied ${strategy} optimization strategy`, improvement, }; } private calculateAverageFadeTime(cues: any[], field: string): number { return cues.reduce((sum, cue) => sum + (cue[field] || 0), 0) / cues.length; } private analyzeCueNumbering(cues: any[]) { const numbers = cues.map((c) => c.cueNumber).sort((a, b) => a - b); return { sequential: this.isSequential(numbers), gaps: this.findGaps(numbers), duplicates: this.findDuplicates(numbers), format: this.analyzeNumberFormat(numbers), }; } private analyzeFadeTimings(cues: any[]) { return { fadeInRange: { min: Math.min(...cues.map((c) => c.fadeInTime)), max: Math.max(...cues.map((c) => c.fadeInTime)), }, fadeOutRange: { min: Math.min(...cues.map((c) => c.fadeOutTime)), max: Math.max(...cues.map((c) => c.fadeOutTime)), }, commonTimes: this.findCommonFadeTimes(cues), }; } private analyzeSceneUsage(cues: any[], scenes: any[]) { const sceneUsage = scenes.map((scene) => ({ sceneId: scene.id, sceneName: scene.name, usageCount: cues.filter((cue) => cue.scene.id === scene.id).length, })); return { totalScenes: scenes.length, usedScenes: sceneUsage.filter((s) => s.usageCount > 0).length, unusedScenes: sceneUsage.filter((s) => s.usageCount === 0), mostUsedScene: sceneUsage.sort((a, b) => b.usageCount - a.usageCount)[0], }; } private analyzeFollowStructure(cues: any[]) { return { totalFollowCues: cues.filter((c) => c.followTime).length, manualCues: cues.filter((c) => !c.followTime).length, averageFollowTime: this.calculateAverageFadeTime( cues.filter((c) => c.followTime), "followTime", ), followChains: this.identifyFollowChains(cues), }; } private findCommonFadeTimes(cues: any[]): number[] { const fadeTimes = [ ...cues.map((c) => c.fadeInTime), ...cues.map((c) => c.fadeOutTime), ]; const timeCounts = fadeTimes.reduce( (acc, time) => { acc[time] = (acc[time] || 0) + 1; return acc; }, {} as Record<number, number>, ); return Object.entries(timeCounts) .filter(([, count]) => (count as number) > 2) .map(([time]) => parseFloat(time)) .sort((a, b) => a - b); } private identifyTimingPatterns(_cues: any[]): string[] { // Simplified pattern identification return [ "Standard 3-second fades", "Quick blackouts at 1 second", "Slow mood transitions at 5+ seconds", ]; } private analyzeSceneTransitions(cues: any[]): any[] { const transitions = []; for (let i = 0; i < cues.length - 1; i++) { transitions.push({ from: cues[i].scene.name, to: cues[i + 1].scene.name, fadeTime: cues[i + 1].fadeInTime, gap: cues[i + 1].cueNumber - cues[i].cueNumber, }); } return transitions; } private identifyPotentialIssues(cues: any[]): string[] { const issues = []; // Check for very fast fade times that might be difficult to execute if (cues.some((c) => c.fadeInTime < 0.5)) { issues.push("Some fade times may be too fast for smooth execution"); } // Check for large gaps in cue numbering const numbers = cues.map((c) => c.cueNumber); if (Math.max(...numbers) - Math.min(...numbers) > cues.length * 2) { issues.push("Large gaps in cue numbering may cause confusion"); } return issues; } private calculateAverageCueSpacing(cues: any[]): number { if (cues.length < 2) return 0; const numbers = cues.map((c) => c.cueNumber).sort((a, b) => a - b); const gaps = []; for (let i = 1; i < numbers.length; i++) { gaps.push(numbers[i] - numbers[i - 1]); } return gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length; } private isSequential(numbers: number[]): boolean { for (let i = 1; i < numbers.length; i++) { if (numbers[i] !== numbers[i - 1] + 1) return false; } return true; } private findGaps(numbers: number[]): number[] { const gaps = []; for (let i = 1; i < numbers.length; i++) { if (numbers[i] - numbers[i - 1] > 1) { gaps.push(numbers[i - 1] + 1); } } return gaps; } private findDuplicates(numbers: number[]): number[] { return numbers.filter((num, index) => numbers.indexOf(num) !== index); } private analyzeNumberFormat(numbers: number[]): string { const hasDecimals = numbers.some((n) => n % 1 !== 0); return hasDecimals ? "Mixed integer and decimal" : "Integer only"; } private identifyFollowChains(cues: any[]): any[] { // Identify sequences of follow cues const chains = []; let currentChain = []; for (const cue of cues) { if (cue.followTime) { currentChain.push(cue.cueNumber); } else { if (currentChain.length > 1) { chains.push([...currentChain]); } currentChain = []; } } if (currentChain.length > 1) { chains.push(currentChain); } return chains; } private recommendNumberingImprovements(_cues: any[]): string[] { return [ "Consider using decimal increments (1.0, 1.5, 2.0) for easier insertion of new cues", ]; } private recommendTimingImprovements(_cues: any[]): string[] { return ["Standardize common fade times to reduce operator confusion"]; } private recommendStructureImprovements(_cues: any[]): string[] { return ["Group related cues with consistent numbering patterns"]; } private recommendSafetyConsiderations(_cues: any[]): string[] { return ["Ensure all blackout cues can be executed manually in emergency"]; } async updateCueList(args: { cueListId: string; name?: string; description?: string; loop?: boolean; }) { const { cueListId, name, description, loop } = args; try { if (!name && !description && loop === undefined) { throw new Error( "At least one field (name, description, or loop) must be provided", ); } const updatedCueList = await this.graphqlClient.updateCueList(cueListId, { name, description, loop, }); return { cueListId: updatedCueList.id, cueList: { name: updatedCueList.name, description: updatedCueList.description, loop: updatedCueList.loop, totalCues: updatedCueList.cues.length, }, success: true, }; } catch (error) { throw new Error(`Failed to update cue list: ${error}`); } } async addCueToCueList(args: { cueListId: string; name: string; cueNumber: number; sceneId: string; fadeInTime?: number; fadeOutTime?: number; followTime?: number; notes?: string; position?: "before" | "after"; referenceCueNumber?: number; }) { const { cueListId, name, cueNumber, sceneId, fadeInTime = 3, fadeOutTime = 3, followTime, notes, position, referenceCueNumber, } = args; try { // If position and reference are provided, adjust cue numbers let finalCueNumber = cueNumber; if (position && referenceCueNumber !== undefined) { // Get existing cues to renumber if needed const cueList = await this.graphqlClient.getCueList(cueListId); if (cueList) { const cues = cueList.cues.sort((a, b) => a.cueNumber - b.cueNumber); const refIndex = cues.findIndex( (c) => c.cueNumber === referenceCueNumber, ); if (refIndex >= 0) { if (position === "before") { const prevCue = refIndex > 0 ? cues[refIndex - 1] : null; finalCueNumber = prevCue ? (prevCue.cueNumber + referenceCueNumber) / 2 : referenceCueNumber - 0.5; } else { const nextCue = refIndex < cues.length - 1 ? cues[refIndex + 1] : null; finalCueNumber = nextCue ? (referenceCueNumber + nextCue.cueNumber) / 2 : referenceCueNumber + 0.5; } } } } const createdCue = await this.graphqlClient.createCue({ name, cueNumber: finalCueNumber, cueListId, sceneId, fadeInTime, fadeOutTime, followTime, notes, }); return { cueId: createdCue.id, cue: { name: createdCue.name, cueNumber: createdCue.cueNumber, sceneName: createdCue.scene.name, fadeInTime: createdCue.fadeInTime, fadeOutTime: createdCue.fadeOutTime, followTime: createdCue.followTime, notes: createdCue.notes, }, success: true, }; } catch (error) { throw new Error(`Failed to add cue to list: ${error}`); } } async removeCueFromList(args: { cueId: string }) { const { cueId } = args; try { const success = await this.graphqlClient.deleteCue(cueId); return { cueId, success, message: success ? "Cue removed successfully" : "Failed to remove cue", }; } catch (error) { throw new Error(`Failed to remove cue: ${error}`); } } async updateCue(args: { cueId: string; name?: string; cueNumber?: number; sceneId?: string; fadeInTime?: number; fadeOutTime?: number; followTime?: number | null; notes?: string; }) { const { cueId, ...updateFields } = args; try { const updatedCue = await this.graphqlClient.updateCue( cueId, updateFields, ); return { cueId: updatedCue.id, cue: { name: updatedCue.name, cueNumber: updatedCue.cueNumber, sceneName: updatedCue.scene.name, fadeInTime: updatedCue.fadeInTime, fadeOutTime: updatedCue.fadeOutTime, followTime: updatedCue.followTime, notes: updatedCue.notes, }, success: true, }; } catch (error) { throw new Error(`Failed to update cue: ${error}`); } } async bulkUpdateCues(args: z.infer<typeof BulkUpdateCuesSchema>) { const { cueIds, fadeInTime, fadeOutTime, followTime, easingType } = BulkUpdateCuesSchema.parse(args); try { if (cueIds.length === 0) { throw new Error("No cue IDs provided for bulk update"); } // Build update data - only include fields that are provided const updateData: BulkUpdateData = {}; if (fadeInTime !== undefined) updateData.fadeInTime = fadeInTime; if (fadeOutTime !== undefined) updateData.fadeOutTime = fadeOutTime; if (followTime !== undefined) updateData.followTime = followTime; if (easingType !== undefined) updateData.easingType = easingType; if (Object.keys(updateData).length === 0) { throw new Error( "No update fields provided. At least one of fadeInTime, fadeOutTime, followTime, or easingType must be specified.", ); } // Use the GraphQL client's bulk update method const updatedCues = await this.graphqlClient.bulkUpdateCues({ cueIds, ...updateData, }); // Format the response const formattedCues = updatedCues.map((cue: CueResponse) => ({ cueId: cue.id, name: cue.name, cueNumber: cue.cueNumber, sceneName: cue.scene.name, fadeInTime: cue.fadeInTime, fadeOutTime: cue.fadeOutTime, followTime: cue.followTime, notes: cue.notes, })); // Generate summary statistics const summary = { totalUpdated: updatedCues.length, updatesApplied: Object.keys(updateData), averageFadeInTime: fadeInTime !== undefined ? fadeInTime : formattedCues.reduce((sum, cue) => sum + cue.fadeInTime, 0) / formattedCues.length, averageFadeOutTime: fadeOutTime !== undefined ? fadeOutTime : formattedCues.reduce((sum, cue) => sum + cue.fadeOutTime, 0) / formattedCues.length, followCuesCount: formattedCues.filter( (cue) => cue.followTime !== null && cue.followTime !== undefined, ).length, }; return { success: true, updatedCues: formattedCues, summary, message: `Successfully updated ${updatedCues.length} cues with: ${Object.keys(updateData).join(", ")}`, }; } catch (error) { throw new Error(`Failed to bulk update cues: ${error}`); } } async reorderCues(args: { cueListId: string; cueReordering: Array<{ cueId: string; newCueNumber: number; }>; }) { const { cueListId, cueReordering } = args; try { // Update each cue with its new number const updatePromises = cueReordering.map(({ cueId, newCueNumber }) => this.graphqlClient.updateCue(cueId, { cueNumber: newCueNumber }), ); const updatedCues = await Promise.all(updatePromises); return { cueListId, updatedCues: updatedCues.map((cue: any) => ({ cueId: cue.id, name: cue.name, cueNumber: cue.cueNumber, })), success: true, totalUpdated: updatedCues.length, }; } catch (error) { throw new Error(`Failed to reorder cues: ${error}`); } } async getCueListDetails(args: { cueListId: string; includeSceneDetails?: boolean; sortBy?: "cueNumber" | "name" | "sceneName"; filterBy?: { cueNumberRange?: { min: number; max: number }; nameContains?: string; sceneNameContains?: string; hasFollowTime?: boolean; fadeTimeRange?: { min: number; max: number }; }; }) { const { cueListId, includeSceneDetails = true, sortBy = "cueNumber", filterBy, } = args; try { const cueList = await this.graphqlClient.getCueList(cueListId); if (!cueList) { throw new Error(`Cue list with ID ${cueListId} not found`); } let cues = [...cueList.cues]; // Apply filters if (filterBy) { if (filterBy.cueNumberRange) { const range = filterBy.cueNumberRange; if (range) { cues = cues.filter( (cue) => cue.cueNumber >= range.min && cue.cueNumber <= range.max, ); } } if (filterBy.nameContains) { cues = cues.filter((cue) => cue.name .toLowerCase() .includes(filterBy.nameContains?.toLowerCase() ?? ""), ); } if (filterBy.sceneNameContains) { cues = cues.filter((cue) => cue.scene.name .toLowerCase() .includes(filterBy.sceneNameContains?.toLowerCase() ?? ""), ); } if (filterBy.hasFollowTime !== undefined) { cues = cues.filter((cue) => filterBy.hasFollowTime ? cue.followTime !== undefined : cue.followTime === undefined, ); } if (filterBy.fadeTimeRange) { const range = filterBy.fadeTimeRange; if (range) { cues = cues.filter( (cue) => cue.fadeInTime >= range.min && cue.fadeInTime <= range.max, ); } } } // Apply sorting switch (sortBy) { case "cueNumber": cues.sort((a, b) => a.cueNumber - b.cueNumber); break; case "name": cues.sort((a, b) => a.name.localeCompare(b.name)); break; case "sceneName": cues.sort((a, b) => a.scene.name.localeCompare(b.scene.name)); break; } // Format cue details const formattedCues = cues.map((cue) => { const baseInfo = { cueId: cue.id, cueNumber: cue.cueNumber, name: cue.name, fadeInTime: cue.fadeInTime, fadeOutTime: cue.fadeOutTime, followTime: cue.followTime, notes: cue.notes, sceneName: cue.scene.name, sceneId: cue.scene.id, }; if (includeSceneDetails) { return { ...baseInfo, scene: { id: cue.scene.id, name: cue.scene.name, description: cue.scene.description, }, }; } return baseInfo; }); // Generate summary statistics const statistics = { totalCues: cues.length, cueNumberRange: cues.length > 0 ? { min: Math.min(...cues.map((c) => c.cueNumber)), max: Math.max(...cues.map((c) => c.cueNumber)), } : null, averageFadeInTime: cues.length > 0 ? cues.reduce((sum, c) => sum + c.fadeInTime, 0) / cues.length : 0, averageFadeOutTime: cues.length > 0 ? cues.reduce((sum, c) => sum + c.fadeOutTime, 0) / cues.length : 0, cuesTotalFollowTime: cues.filter((c) => c.followTime).length, uniqueScenes: [...new Set(cues.map((c) => c.scene.id))].length, estimatedTotalTime: this.estimateSequenceDuration(cues), }; // Create lookup tables for easy reference const lookupTables = { byCueNumber: Object.fromEntries( cues.map((cue) => [ cue.cueNumber.toString(), { cueId: cue.id, name: cue.name, sceneName: cue.scene.name, }, ]), ), byName: Object.fromEntries( cues.map((cue) => [ cue.name.toLowerCase(), { cueId: cue.id, cueNumber: cue.cueNumber, sceneName: cue.scene.name, }, ]), ), bySceneName: cues.reduce((acc: any, cue) => { const sceneName = cue.scene.name.toLowerCase(); if (!acc[sceneName]) { acc[sceneName] = []; } acc[sceneName].push({ cueId: cue.id, cueNumber: cue.cueNumber, cueName: cue.name, }); return acc; }, {}), bySceneId: cues.reduce((acc: any, cue) => { const sceneId = cue.scene.id; if (!acc[sceneId]) { acc[sceneId] = []; } acc[sceneId].push({ cueId: cue.id, cueNumber: cue.cueNumber, cueName: cue.name, }); return acc; }, {}), }; return { cueListId: cueList.id, cueList: { name: cueList.name, description: cueList.description, projectId: (cueList as any).project?.id, }, cues: formattedCues, statistics, lookupTables, query: { sortedBy: sortBy, filtersApplied: filterBy ? Object.keys(filterBy).length : 0, totalBeforeFiltering: cueList.cues.length, totalAfterFiltering: cues.length, }, }; } catch (error) { throw new Error(`Failed to get cue list details: ${error}`); } } async deleteCueList(args: { cueListId: string; confirmDelete: boolean }) { const { cueListId, confirmDelete } = args; try { if (!confirmDelete) { throw new Error("confirmDelete must be true to delete a cue list"); } // Get the cue list details before deleting for the response const cueList = await this.graphqlClient.getCueList(cueListId); if (!cueList) { throw new Error(`Cue list with ID ${cueListId} not found`); } const cueListInfo = { id: cueList.id, name: cueList.name, description: cueList.description, totalCues: cueList.cues.length, }; const success = await this.graphqlClient.deleteCueList(cueListId); return { cueListId, deletedCueList: cueListInfo, success, message: success ? "Cue list deleted successfully" : "Failed to delete cue list", }; } catch (error) { throw new Error(`Failed to delete cue list: ${error}`); } } // Playback control methods async startCueList(args: z.infer<typeof StartCueListSchema>) { const { cueListId, cueListName, projectId, startFromCue } = StartCueListSchema.parse(args); try { let resolvedCueListId = cueListId; // Find cue list by name if not provided by ID if (!cueListId && cueListName) { if (!projectId) { // Search across all projects const projects = await this.graphqlClient.getProjects(); for (const project of projects) { const cueList = project.cueLists.find( (cl) => cl.name.toLowerCase() === cueListName.toLowerCase() || cl.name.toLowerCase().includes(cueListName.toLowerCase()), ); if (cueList) { resolvedCueListId = cueList.id; break; } } } else { // Search in specific project const project = await this.graphqlClient.getProject(projectId); if (!project) { throw new Error(`Project with ID ${projectId} not found`); } const cueList = project.cueLists.find( (cl) => cl.name.toLowerCase() === cueListName.toLowerCase() || cl.name.toLowerCase().includes(cueListName.toLowerCase()), ); if (cueList) { resolvedCueListId = cueList.id; } } if (!resolvedCueListId) { throw new Error(`Cue list with name "${cueListName}" not found`); } } if (!resolvedCueListId) { throw new Error("Either cueListId or cueListName must be provided"); } // Get the full cue list with cues const cueList = await this.graphqlClient.getCueList(resolvedCueListId); if (!cueList) { throw new Error(`Cue list with ID ${resolvedCueListId} not found`); } if (cueList.cues.length === 0) { throw new Error("Cue list has no cues to play"); } // Sort cues by cue number const sortedCues = cueList.cues.sort((a, b) => a.cueNumber - b.cueNumber); // Find starting cue index let startIndex = 0; if (startFromCue !== undefined) { startIndex = sortedCues.findIndex( (cue) => cue.cueNumber === startFromCue, ); if (startIndex === -1) { throw new Error(`Cue number ${startFromCue} not found in cue list`); } } // Use backend mutation to start the cue list await this.graphqlClient.startCueList(resolvedCueListId, startIndex); const firstCue = sortedCues[startIndex]; return { success: true, cueList: { id: cueList.id, name: cueList.name, totalCues: sortedCues.length, }, currentCue: { index: startIndex + 1, // 1-based for user display number: firstCue.cueNumber, name: firstCue.name, scene: firstCue.scene.name, }, message: `Started playing cue list "${cueList.name}" from cue ${firstCue.cueNumber}`, }; } catch (error) { throw new Error(`Failed to start cue list: ${error}`); } } // Helper function to get scene name for a cue by index private getSceneNameFromCueList(cueList: any, cueIndex: number): string { const sortedCues = cueList.cues.sort( (a: any, b: any) => a.cueNumber - b.cueNumber, ); if ( cueIndex >= 0 && cueIndex < sortedCues.length && sortedCues[cueIndex].scene ) { return sortedCues[cueIndex].scene.name; } return "Unknown Scene"; } // Helper method to find cue list containing a specific scene with caching private async findCueListForScene( sceneId: string, ): Promise<{ cueListId: string; cueId: string } | null> { // Check cache first const cached = this.sceneToCueListCache.get(sceneId); if (cached && cached.expiresAt > Date.now()) { return { cueListId: cached.cueListId, cueId: cached.cueId }; } // Cache miss or expired - search through projects try { const projects = await this.graphqlClient.getProjects(); for (const project of projects) { for (const cueList of project.cueLists) { try { const fullCueList = await this.graphqlClient.getCueList(cueList.id); if (!fullCueList) continue; const matchingCue = fullCueList.cues.find( (cue) => cue.scene.id === sceneId, ); if (matchingCue) { // Cache the result this.sceneToCueListCache.set(sceneId, { cueListId: cueList.id, cueId: matchingCue.id, expiresAt: Date.now() + this.CACHE_TTL_MS, }); return { cueListId: cueList.id, cueId: matchingCue.id }; } } catch (_error) { // Skip this cue list if we can't access it continue; } } } } catch (_error) { // If we can't search, return null return null; } return null; } async nextCue(args: z.infer<typeof NextCueSchema>) { const { fadeInTime } = NextCueSchema.parse(args); const cueListId = await this.getActiveCueListId(); try { // Use backend mutation to advance to next cue await this.graphqlClient.nextCue(cueListId, fadeInTime); // Get updated playback status from backend const status = await this.graphqlClient.getCueListPlaybackStatus(cueListId); if (!status || status.currentCueIndex === null) { return { success: false, message: "Could not advance to next cue", }; } // Get cue list to extract scene name const cueList = await this.graphqlClient.getCueList(cueListId); const sceneName = cueList ? this.getSceneNameFromCueList(cueList, status.currentCueIndex) : "Unknown Scene"; return { success: true, currentCue: { index: status.currentCueIndex + 1, // 1-based for user display number: status.currentCue?.cueNumber || 0, name: status.currentCue?.name || "", scene: sceneName, }, fadeTime: fadeInTime || status.currentCue?.fadeInTime, message: `Advanced to cue ${status.currentCue?.cueNumber} - "${status.currentCue?.name}"`, }; } catch (error) { throw new Error(`Failed to advance to next cue: ${error}`); } } async previousCue(args: z.infer<typeof PreviousCueSchema>) { const { fadeInTime } = PreviousCueSchema.parse(args); const cueListId = await this.getActiveCueListId(); try { // Use backend mutation to go to previous cue await this.graphqlClient.previousCue(cueListId, fadeInTime); // Get updated playback status from backend const status = await this.graphqlClient.getCueListPlaybackStatus(cueListId); if (!status || status.currentCueIndex === null) { return { success: false, message: "Could not go to previous cue", }; } // Get cue list to extract scene name const cueList = await this.graphqlClient.getCueList(cueListId); const sceneName = cueList ? this.getSceneNameFromCueList(cueList, status.currentCueIndex) : "Unknown Scene"; return { success: true, currentCue: { index: status.currentCueIndex + 1, // 1-based for user display number: status.currentCue?.cueNumber || 0, name: status.currentCue?.name || "", scene: sceneName, }, fadeTime: fadeInTime || status.currentCue?.fadeInTime, message: `Went back to cue ${status.currentCue?.cueNumber} - "${status.currentCue?.name}"`, }; } catch (error) { throw new Error(`Failed to go back to previous cue: ${error}`); } } async goToCue(args: z.infer<typeof GoToCueSchema>) { const { cueNumber, cueName, fadeInTime } = GoToCueSchema.parse(args); if (!cueNumber && !cueName) { throw new Error("Either cueNumber or cueName must be provided"); } const cueListId = await this.getActiveCueListId(); try { // Get the cue list to find the target cue index const cueList = await this.graphqlClient.getCueList(cueListId); if (!cueList) { throw new Error("Active cue list not found"); } const sortedCues = cueList.cues.sort((a, b) => a.cueNumber - b.cueNumber); let targetIndex = -1; if (cueNumber !== undefined) { targetIndex = sortedCues.findIndex( (cue) => cue.cueNumber === cueNumber, ); } else if (cueName) { targetIndex = sortedCues.findIndex( (cue) => cue.name.toLowerCase() === cueName.toLowerCase() || cue.name.toLowerCase().includes(cueName.toLowerCase()), ); } if (targetIndex === -1) { const searchTerm = cueNumber !== undefined ? `number ${cueNumber}` : `name "${cueName}"`; throw new Error(`Cue with ${searchTerm} not found in current cue list`); } // Use backend mutation to go to specific cue await this.graphqlClient.goToCue(cueListId, targetIndex, fadeInTime); // Get updated status const status = await this.graphqlClient.getCueListPlaybackStatus(cueListId); const sceneName = this.getSceneNameFromCueList(cueList, targetIndex); return { success: true, currentCue: { index: targetIndex + 1, // 1-based for user display number: status?.currentCue?.cueNumber || sortedCues[targetIndex].cueNumber, name: status?.currentCue?.name || sortedCues[targetIndex].name, scene: sceneName, }, fadeTime: fadeInTime || status?.currentCue?.fadeInTime, message: `Jumped to cue ${status?.currentCue?.cueNumber} - "${status?.currentCue?.name}"`, }; } catch (error) { throw new Error(`Failed to go to cue: ${error}`); } } async stopCueList(_args: z.infer<typeof _StopCueListSchema>) { try { const cueListId = await this.getActiveCueListId(); // Get current status before stopping const status = await this.graphqlClient.getCueListPlaybackStatus(cueListId); // Use backend mutation to stop await this.graphqlClient.stopCueList(cueListId); return { success: true, stoppedCueList: { lastPlayedCue: status?.currentCue ? { number: status.currentCue.cueNumber, name: status.currentCue.name, } : null, }, message: `Stopped cue list playback`, }; } catch (error) { // If no cue list is playing, return a success message if (error instanceof Error && error.message.includes("No cue list is currently playing")) { return { success: true, message: "No cue list is currently active", }; } throw new Error(`Failed to stop cue list: ${error}`); } } async getCueListStatus(_args: z.infer<typeof _GetCueListStatusSchema>) { try { // Query backend for active cue list playback try { const cueListId = await this.getActiveCueListId(); const status = await this.graphqlClient.getCueListPlaybackStatus(cueListId); if (status && status.isPlaying) { // Active cue list playback - return full status const cueList = await this.graphqlClient.getCueList(cueListId); if (cueList) { const sortedCues = cueList.cues.sort( (a, b) => a.cueNumber - b.cueNumber, ); const currentIndex = status.currentCueIndex || 0; const isFirstCue = currentIndex === 0; const isLastCue = currentIndex === sortedCues.length - 1; return { isPlaying: true, cueList: { id: cueListId, name: cueList.name, totalCues: sortedCues.length, }, currentCue: status.currentCue ? { index: currentIndex + 1, number: status.currentCue.cueNumber, name: status.currentCue.name, scene: this.getSceneNameFromCueList(cueList, currentIndex), fadeInTime: status.currentCue.fadeInTime, fadeOutTime: status.currentCue.fadeOutTime, followTime: status.currentCue.followTime, } : null, navigation: { canGoPrevious: !isFirstCue, canGoNext: !isLastCue, previousCue: !isFirstCue && currentIndex > 0 ? { number: sortedCues[currentIndex - 1].cueNumber, name: sortedCues[currentIndex - 1].name, } : null, nextCue: !isLastCue && currentIndex < sortedCues.length - 1 ? { number: sortedCues[currentIndex + 1].cueNumber, name: sortedCues[currentIndex + 1].name, } : null, }, startedAt: status.lastUpdated, }; } } } catch (_error) { // No active cue list playback, continue to check for active scene } // No active cue list playback - check if there's an active scene that matches a cue const currentScene = await this.graphqlClient.getCurrentActiveScene(); if (!currentScene) { return { isPlaying: false, message: "No cue list is currently playing and no active scene was found", }; } // Use cached search to find cue list containing this scene const cueListMatch = await this.findCueListForScene(currentScene.id); if (cueListMatch) { try { const fullCueList = await this.graphqlClient.getCueList( cueListMatch.cueListId, ); if (fullCueList) { const matchingCue = fullCueList.cues.find( (cue) => cue.id === cueListMatch.cueId, ); if (matchingCue) { const sortedCues = fullCueList.cues.sort( (a, b) => a.cueNumber - b.cueNumber, ); const currentIndex = sortedCues.findIndex( (cue) => cue.id === matchingCue.id, ); const isFirstCue = currentIndex === 0; const isLastCue = currentIndex === sortedCues.length - 1; return { isPlaying: false, // Scene is active but not formal cue list playback cueList: { id: cueListMatch.cueListId, name: fullCueList.name, totalCues: sortedCues.length, }, currentCue: { index: currentIndex + 1, number: matchingCue.cueNumber, name: matchingCue.name, scene: currentScene.name, fadeInTime: matchingCue.fadeInTime, fadeOutTime: matchingCue.fadeOutTime, followTime: matchingCue.followTime, }, navigation: { canGoPrevious: !isFirstCue, canGoNext: !isLastCue, previousCue: !isFirstCue && currentIndex > 0 ? { number: sortedCues[currentIndex - 1].cueNumber, name: sortedCues[currentIndex - 1].name, } : null, nextCue: !isLastCue && currentIndex < sortedCues.length - 1 ? { number: sortedCues[currentIndex + 1].cueNumber, name: sortedCues[currentIndex + 1].name, } : null, }, message: `Scene "${currentScene.name}" matches cue ${matchingCue.cueNumber} in "${fullCueList.name}". Use start_cue_list to enable formal playback.`, startedAt: new Date().toISOString(), }; } } } catch (_error) { // If we can't get the cue list details, fall through to the not found case } } return { isPlaying: false, message: `Active scene "${currentScene.name}" was found but does not match any cue in available cue lists`, }; } catch (error) { throw new Error(`Failed to get cue list status: ${error}`); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bbernstein/lacylights-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server