diff --git a/ui/src/features/agent/agentUiBus.ts b/ui/src/features/agent/agentUiBus.ts index acf65238..7f94f4d4 100644 --- a/ui/src/features/agent/agentUiBus.ts +++ b/ui/src/features/agent/agentUiBus.ts @@ -1,354 +1 @@ -import type { SeriesJobStatus } from '../series/types' -import type { SeriesAssemblyJob } from '../series/assemblyContract' - -export type AgentStorySection = 'overview' | 'assets' | 'world' | 'characters' | 'relationships' | 'structure' | 'music' | 'trailer' | 'productions' | 'assembly' -export type AgentSeriesSection = 'setup' | 'canon' | 'episode' | 'shots' | 'review' -export type AgentSeriesReviewView = 'assembly' | 'history' | 'finish' - -const STORY_SECTION_EVENT = 'hocuspocus:story-section' -const SERIES_SECTION_EVENT = 'hocuspocus:series-section' -const STORY_DRAFT_EVENT = 'hocuspocus:story-draft-ready' -const SERIES_PLAN_JOB_EVENT = 'hocuspocus:series-plan-job' -const SERIES_RENDER_JOB_EVENT = 'hocuspocus:series-render-job' -const SERIES_ASSEMBLY_JOB_EVENT = 'hocuspocus:series-assembly-job' -const SERIES_REVIEW_VIEW_EVENT = 'hocuspocus:series-review-view' -let requestedStorySection: AgentStorySection | null = null -let requestedSeriesSection: AgentSeriesSection | null = null -let requestedSeriesPlanJob: SeriesJobStatus | null = null -let requestedSeriesRenderJob: SeriesJobStatus | null = null -let requestedSeriesAssemblyJob: SeriesAssemblyJob | null = null -let requestedSeriesReviewView: AgentSeriesReviewView | null = null - -export interface AgentStoryVisualGenerationRequest { - projectId: string - scope: 'world' | 'locations' | 'characters' | 'all' - targetNames: string[] -} - -export interface AgentStoryVisualGenerationResult { - message: string - assetIds: string[] -} - -interface PendingStoryVisualGenerationRequest { - request: AgentStoryVisualGenerationRequest - resolve: (result: AgentStoryVisualGenerationResult) => void - reject: (error: Error) => void -} - -const STORY_VISUAL_GENERATION_EVENT = 'hocuspocus:story-visual-generation' -const pendingStoryVisualGenerationRequests: PendingStoryVisualGenerationRequest[] = [] - -export function requestAgentStoryVisualGeneration(request: AgentStoryVisualGenerationRequest): Promise { - return new Promise((resolve, reject) => { - pendingStoryVisualGenerationRequests.push({ request, resolve, reject }) - window.dispatchEvent(new CustomEvent(STORY_VISUAL_GENERATION_EVENT)) - }) -} - -export function listenForAgentStoryVisualGeneration( - listener: (request: AgentStoryVisualGenerationRequest) => Promise, -): () => void { - let active = true - const drain = async () => { - while (active && pendingStoryVisualGenerationRequests.length) { - const pending = pendingStoryVisualGenerationRequests.shift() - if (!pending) continue - try { pending.resolve(await listener(pending.request)) } - catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } - } - } - const handler = () => { void drain() } - window.addEventListener(STORY_VISUAL_GENERATION_EVENT, handler) - void drain() - return () => { active = false; window.removeEventListener(STORY_VISUAL_GENERATION_EVENT, handler) } -} - -export type AgentSceneControlRequest = - | { type: 'open_3d_scene'; sceneName: string; layerName: string } - | { type: 'save_3d_scene'; sceneName: string } - | { type: 'export_3d_scene'; sceneName: string } - -export type AgentSceneWorkflowRequest = - | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 30 | 60; reset?: boolean } - | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 30 | 60 } - | { type: 'add_3d_scene_layer'; sceneName: string; layerName: string; layerType: 'model3d' | 'image' | 'video' | 'overlay' | 'camera'; outputName?: string } - | { type: 'update_3d_scene_layer'; sceneName: string; layerName: string; visible?: boolean; locked?: boolean } - | { type: 'remove_3d_scene_layer'; sceneName: string; layerName: string } - | { type: 'attach_3d_scene_audio'; sceneName: string; audioOutputName: string } - | { type: 'analyze_3d_scene_audio'; sceneName: string; audioOutputName: string } - | { type: 'apply_3d_choreography'; sceneName: string; layerName: string; audioOutputName: string; cueSource: 'beats' | 'downbeats'; profile: 'pulse' | 'bounce' | 'peek' | 'camera-punch'; intensity: number; rhythmGrid?: AgentRhythmGrid } - | AgentSceneControlRequest - -export interface AgentSceneWorkflowResult { - message: string - sceneId?: string - layerIds?: string[] - audioTrackId?: string - analysisId?: string - bpm?: number - beatCount?: number - downbeatCount?: number - outputNames?: string[] - rhythmGrid?: AgentRhythmGrid -} - -export interface AgentRhythmGrid { - duration: number - bpm: number - beats: Array<{ time: number; strength: number }> - downbeats: number[] -} - -interface PendingSceneWorkflowRequest { - request: AgentSceneWorkflowRequest - resolve: (result: AgentSceneWorkflowResult) => void - reject: (error: Error) => void -} - -const SCENE_WORKFLOW_REQUEST_EVENT = 'hocuspocus:scene-workflow-request' -const pendingSceneWorkflowRequests: PendingSceneWorkflowRequest[] = [] - -export function requestAgentSceneWorkflow(request: AgentSceneWorkflowRequest): Promise { - return new Promise((resolve, reject) => { - pendingSceneWorkflowRequests.push({ request, resolve, reject }) - window.dispatchEvent(new CustomEvent(SCENE_WORKFLOW_REQUEST_EVENT)) - }) -} - -export function listenForAgentSceneWorkflow( - listener: (request: AgentSceneWorkflowRequest) => Promise, -): () => void { - let active = true - const drain = async () => { - while (active && pendingSceneWorkflowRequests.length) { - const pending = pendingSceneWorkflowRequests.shift() - if (!pending) continue - try { pending.resolve(await listener(pending.request)) } - catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } - } - } - const handler = () => { void drain() } - window.addEventListener(SCENE_WORKFLOW_REQUEST_EVENT, handler) - void drain() - return () => { active = false; window.removeEventListener(SCENE_WORKFLOW_REQUEST_EVENT, handler) } -} - -interface PendingSceneControlRequest { - request: AgentSceneControlRequest - resolve: (message: string) => void - reject: (error: Error) => void -} - -const SCENE_CONTROL_REQUEST_EVENT = 'hocuspocus:scene-control-request' -const pendingSceneControlRequests: PendingSceneControlRequest[] = [] - -export function requestAgentSceneControl(request: AgentSceneControlRequest): Promise { - return new Promise((resolve, reject) => { - pendingSceneControlRequests.push({ request, resolve, reject }) - window.dispatchEvent(new CustomEvent(SCENE_CONTROL_REQUEST_EVENT)) - }) -} - -export function listenForAgentSceneControl( - listener: (request: AgentSceneControlRequest) => Promise, -): () => void { - let active = true - const drain = async () => { - while (active && pendingSceneControlRequests.length) { - const pending = pendingSceneControlRequests.shift() - if (!pending) continue - try { pending.resolve(await listener(pending.request)) } - catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } - } - } - const handler = () => { void drain() } - window.addEventListener(SCENE_CONTROL_REQUEST_EVENT, handler) - void drain() - return () => { active = false; window.removeEventListener(SCENE_CONTROL_REQUEST_EVENT, handler) } -} - -export interface AgentSceneRhythmRequest { - sceneName: string - layerName: string - audioOutputName: string - cueSource: 'beats' | 'downbeats' - profile: 'pulse' | 'bounce' | 'peek' | 'camera-punch' - intensity: number -} - -interface PendingSceneRhythmRequest { - request: AgentSceneRhythmRequest - resolve: (message: string) => void - reject: (error: Error) => void -} - -const SCENE_RHYTHM_REQUEST_EVENT = 'hocuspocus:scene-rhythm-request' -const pendingSceneRhythmRequests: PendingSceneRhythmRequest[] = [] - -export function requestAgentSceneRhythm(request: AgentSceneRhythmRequest): Promise { - return new Promise((resolve, reject) => { - const pending = { request, resolve, reject } - pendingSceneRhythmRequests.push(pending) - window.dispatchEvent(new CustomEvent(SCENE_RHYTHM_REQUEST_EVENT)) - }) -} - -export function listenForAgentSceneRhythm( - listener: (request: AgentSceneRhythmRequest) => Promise, -): () => void { - let active = true - const drain = async () => { - while (active && pendingSceneRhythmRequests.length) { - const pending = pendingSceneRhythmRequests.shift() - if (!pending) continue - try { pending.resolve(await listener(pending.request)) } - catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } - } - } - const handler = () => { void drain() } - window.addEventListener(SCENE_RHYTHM_REQUEST_EVENT, handler) - void drain() - return () => { active = false; window.removeEventListener(SCENE_RHYTHM_REQUEST_EVENT, handler) } -} - -export function openAgentStorySection(section: AgentStorySection): void { - requestedStorySection = section - window.dispatchEvent(new CustomEvent(STORY_SECTION_EVENT, { detail: { section } })) -} - -export function openAgentSeriesSection(section: AgentSeriesSection): void { - requestedSeriesSection = section - window.dispatchEvent(new CustomEvent(SERIES_SECTION_EVENT, { detail: { section } })) -} - -export function listenForAgentStorySection( - listener: (section: AgentStorySection) => void, -): () => void { - const handler = (event: Event) => { - const section = (event as CustomEvent<{ section?: AgentStorySection }>).detail?.section - if (section) listener(section) - } - window.addEventListener(STORY_SECTION_EVENT, handler) - if (requestedStorySection) listener(requestedStorySection) - return () => window.removeEventListener(STORY_SECTION_EVENT, handler) -} - -export function listenForAgentSeriesSection( - listener: (section: AgentSeriesSection) => void, -): () => void { - const handler = (event: Event) => { - const section = (event as CustomEvent<{ section?: AgentSeriesSection }>).detail?.section - if (section) listener(section) - } - window.addEventListener(SERIES_SECTION_EVENT, handler) - if (requestedSeriesSection) listener(requestedSeriesSection) - return () => window.removeEventListener(SERIES_SECTION_EVENT, handler) -} - -export function notifyAgentStoryDraft(projectId: string): void { - window.dispatchEvent(new CustomEvent(STORY_DRAFT_EVENT, { detail: { projectId } })) -} - -export function listenForAgentStoryDraft(listener: (projectId: string) => void): () => void { - const handler = (event: Event) => { - const projectId = (event as CustomEvent<{ projectId?: string }>).detail?.projectId - if (projectId) listener(projectId) - } - window.addEventListener(STORY_DRAFT_EVENT, handler) - return () => window.removeEventListener(STORY_DRAFT_EVENT, handler) -} - -export function notifyAgentSeriesPlanJob(job: SeriesJobStatus): void { - requestedSeriesPlanJob = job - window.dispatchEvent(new CustomEvent(SERIES_PLAN_JOB_EVENT, { detail: { job, episodeId: job.episodeId } })) -} - -export function clearAgentSeriesPlanJob(episodeId: string): void { - if (requestedSeriesPlanJob?.episodeId === episodeId) requestedSeriesPlanJob = null - window.dispatchEvent(new CustomEvent(SERIES_PLAN_JOB_EVENT, { detail: { job: null, episodeId } })) -} - -export function getAgentSeriesPlanJob(): SeriesJobStatus | null { - return requestedSeriesPlanJob -} - -export function listenForAgentSeriesPlanJob( - listener: (job: SeriesJobStatus | null, episodeId: string) => void, -): () => void { - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ job?: SeriesJobStatus | null; episodeId?: string }>).detail - if (detail?.episodeId) listener(detail.job || null, detail.episodeId) - } - window.addEventListener(SERIES_PLAN_JOB_EVENT, handler) - if (requestedSeriesPlanJob) listener(requestedSeriesPlanJob, requestedSeriesPlanJob.episodeId) - return () => window.removeEventListener(SERIES_PLAN_JOB_EVENT, handler) -} - -export function notifyAgentSeriesRenderJob(job: SeriesJobStatus): void { - requestedSeriesRenderJob = job - window.dispatchEvent(new CustomEvent(SERIES_RENDER_JOB_EVENT, { detail: { job } })) -} - -export function getAgentSeriesRenderJob(): SeriesJobStatus | null { - return requestedSeriesRenderJob -} - -export function listenForAgentSeriesRenderJob(listener: (job: SeriesJobStatus) => void): () => void { - const handler = (event: Event) => { - const job = (event as CustomEvent<{ job?: SeriesJobStatus }>).detail?.job - if (job) listener(job) - } - window.addEventListener(SERIES_RENDER_JOB_EVENT, handler) - if (requestedSeriesRenderJob) listener(requestedSeriesRenderJob) - return () => window.removeEventListener(SERIES_RENDER_JOB_EVENT, handler) -} - -export function notifyAgentSeriesAssemblyJob(job: SeriesAssemblyJob): void { - requestedSeriesAssemblyJob = job - window.dispatchEvent(new CustomEvent(SERIES_ASSEMBLY_JOB_EVENT, { detail: { job } })) -} - -export function getAgentSeriesAssemblyJob(): SeriesAssemblyJob | null { - return requestedSeriesAssemblyJob -} - -export function listenForAgentSeriesAssemblyJob(listener: (job: SeriesAssemblyJob) => void): () => void { - const handler = (event: Event) => { - const job = (event as CustomEvent<{ job?: SeriesAssemblyJob }>).detail?.job - if (job) listener(job) - } - window.addEventListener(SERIES_ASSEMBLY_JOB_EVENT, handler) - if (requestedSeriesAssemblyJob) listener(requestedSeriesAssemblyJob) - return () => window.removeEventListener(SERIES_ASSEMBLY_JOB_EVENT, handler) -} - -export function openAgentSeriesReviewView(view: AgentSeriesReviewView): void { - requestedSeriesReviewView = view - window.dispatchEvent(new CustomEvent(SERIES_REVIEW_VIEW_EVENT, { detail: { view } })) -} - -export function listenForAgentSeriesReviewView(listener: (view: AgentSeriesReviewView) => void): () => void { - const handler = (event: Event) => { - const view = (event as CustomEvent<{ view?: AgentSeriesReviewView }>).detail?.view - if (view) listener(view) - } - window.addEventListener(SERIES_REVIEW_VIEW_EVENT, handler) - if (requestedSeriesReviewView) listener(requestedSeriesReviewView) - return () => window.removeEventListener(SERIES_REVIEW_VIEW_EVENT, handler) -} - -const ACTIVITY_DETAILS_EVENT = 'hocuspocus:activity-details' -let requestedActivityDetails = false - -export function openAgentActivityDetails(): void { - requestedActivityDetails = true - window.dispatchEvent(new CustomEvent(ACTIVITY_DETAILS_EVENT)) -} - -export function listenForAgentActivityDetails(listener: () => void): () => void { - const handler = () => listener() - window.addEventListener(ACTIVITY_DETAILS_EVENT, handler) - if (requestedActivityDetails) listener() - return () => window.removeEventListener(ACTIVITY_DETAILS_EVENT, handler) -} +export * from '../../lib/uiBus' diff --git a/ui/src/features/series/SeriesEpisodePanel.tsx b/ui/src/features/series/SeriesEpisodePanel.tsx index d68cf263..6598a183 100644 --- a/ui/src/features/series/SeriesEpisodePanel.tsx +++ b/ui/src/features/series/SeriesEpisodePanel.tsx @@ -6,7 +6,7 @@ import { Pill, SectionCard, SeriesField } from './components' import { SeriesEpisodeProposalReview } from './SeriesEpisodeProposalReview' import { inputClass, primaryButton, secondaryButton, textareaClass } from './styles' import type { SeriesEpisode, SeriesJobStatus, SeriesProject } from './types' -import { listenForAgentSeriesPlanJob } from '../agent/agentUiBus' +import { listenForAgentSeriesPlanJob } from '../../lib/uiBus' export function SeriesEpisodePanel({ workspace, series, episode, updateEpisode, saveNow, reload, diff --git a/ui/src/features/series/SeriesLabPanel.tsx b/ui/src/features/series/SeriesLabPanel.tsx index 2b244ef0..bf40f989 100644 --- a/ui/src/features/series/SeriesLabPanel.tsx +++ b/ui/src/features/series/SeriesLabPanel.tsx @@ -11,7 +11,7 @@ import { SeriesReviewPanel } from './SeriesReviewPanel' import { Pill } from './components' import { primaryButton, secondaryButton } from './styles' import type { SeriesJobStatus, SeriesProject } from './types' -import { listenForAgentSeriesRenderJob, listenForAgentSeriesSection } from '../agent/agentUiBus' +import { listenForAgentSeriesRenderJob, listenForAgentSeriesSection } from '../../lib/uiBus' type LabTab = 'setup' | 'canon' | 'episode' | 'shots' | 'review' const tabs: Array<{ id: LabTab; label: string }> = [ diff --git a/ui/src/features/series/SeriesReviewPanel.tsx b/ui/src/features/series/SeriesReviewPanel.tsx index 76b57173..29932399 100644 --- a/ui/src/features/series/SeriesReviewPanel.tsx +++ b/ui/src/features/series/SeriesReviewPanel.tsx @@ -8,7 +8,7 @@ import { Pill, SectionCard } from './components' import { SeriesShotDurationControl } from './SeriesShotDurationControl' import { greenButton, primaryButton, secondaryButton } from './styles' import type { SeriesAssemblyJob, SeriesEpisode, SeriesJobStatus, SeriesProject, SeriesRenderAttempt, SeriesShot } from './types' -import { listenForAgentSeriesAssemblyJob, listenForAgentSeriesReviewView } from '../agent/agentUiBus' +import { listenForAgentSeriesAssemblyJob, listenForAgentSeriesReviewView } from '../../lib/uiBus' function AttemptPreview({ series, attempt, approved, onApprove, onReject }: { series: SeriesProject; attempt: SeriesRenderAttempt; approved: boolean; onApprove: () => void; onReject: () => void diff --git a/ui/src/features/stories/StoryLabPanel.tsx b/ui/src/features/stories/StoryLabPanel.tsx index ee7b993c..39133cf5 100644 --- a/ui/src/features/stories/StoryLabPanel.tsx +++ b/ui/src/features/stories/StoryLabPanel.tsx @@ -49,7 +49,7 @@ import type { } from './types' import type { AspectRatio, ModelOptions, ResolutionPreset } from '../../types' import { ACE_STEP_MUSIC_MODEL, isAceStepMusicModel, normalizeStoryMusicModel, songWriteTarget } from './musicModel' -import { listenForAgentStoryDraft, listenForAgentStorySection, listenForAgentStoryVisualGeneration } from '../agent/agentUiBus' +import { listenForAgentStoryDraft, listenForAgentStorySection, listenForAgentStoryVisualGeneration } from '../../lib/uiBus' const button = 'inline-flex items-center justify-center gap-1.5 rounded-md border border-border bg-bg-tertiary px-2.5 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors' const input = 'w-full rounded-md border border-border bg-bg-tertiary px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent-blue' diff --git a/ui/src/lib/uiBus.ts b/ui/src/lib/uiBus.ts new file mode 100644 index 00000000..11b41177 --- /dev/null +++ b/ui/src/lib/uiBus.ts @@ -0,0 +1,354 @@ +import type { SeriesJobStatus } from '../features/series/types' +import type { SeriesAssemblyJob } from '../features/series/assemblyContract' + +export type AgentStorySection = 'overview' | 'assets' | 'world' | 'characters' | 'relationships' | 'structure' | 'music' | 'trailer' | 'productions' | 'assembly' +export type AgentSeriesSection = 'setup' | 'canon' | 'episode' | 'shots' | 'review' +export type AgentSeriesReviewView = 'assembly' | 'history' | 'finish' + +const STORY_SECTION_EVENT = 'hocuspocus:story-section' +const SERIES_SECTION_EVENT = 'hocuspocus:series-section' +const STORY_DRAFT_EVENT = 'hocuspocus:story-draft-ready' +const SERIES_PLAN_JOB_EVENT = 'hocuspocus:series-plan-job' +const SERIES_RENDER_JOB_EVENT = 'hocuspocus:series-render-job' +const SERIES_ASSEMBLY_JOB_EVENT = 'hocuspocus:series-assembly-job' +const SERIES_REVIEW_VIEW_EVENT = 'hocuspocus:series-review-view' +let requestedStorySection: AgentStorySection | null = null +let requestedSeriesSection: AgentSeriesSection | null = null +let requestedSeriesPlanJob: SeriesJobStatus | null = null +let requestedSeriesRenderJob: SeriesJobStatus | null = null +let requestedSeriesAssemblyJob: SeriesAssemblyJob | null = null +let requestedSeriesReviewView: AgentSeriesReviewView | null = null + +export interface AgentStoryVisualGenerationRequest { + projectId: string + scope: 'world' | 'locations' | 'characters' | 'all' + targetNames: string[] +} + +export interface AgentStoryVisualGenerationResult { + message: string + assetIds: string[] +} + +interface PendingStoryVisualGenerationRequest { + request: AgentStoryVisualGenerationRequest + resolve: (result: AgentStoryVisualGenerationResult) => void + reject: (error: Error) => void +} + +const STORY_VISUAL_GENERATION_EVENT = 'hocuspocus:story-visual-generation' +const pendingStoryVisualGenerationRequests: PendingStoryVisualGenerationRequest[] = [] + +export function requestAgentStoryVisualGeneration(request: AgentStoryVisualGenerationRequest): Promise { + return new Promise((resolve, reject) => { + pendingStoryVisualGenerationRequests.push({ request, resolve, reject }) + window.dispatchEvent(new CustomEvent(STORY_VISUAL_GENERATION_EVENT)) + }) +} + +export function listenForAgentStoryVisualGeneration( + listener: (request: AgentStoryVisualGenerationRequest) => Promise, +): () => void { + let active = true + const drain = async () => { + while (active && pendingStoryVisualGenerationRequests.length) { + const pending = pendingStoryVisualGenerationRequests.shift() + if (!pending) continue + try { pending.resolve(await listener(pending.request)) } + catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } + } + } + const handler = () => { void drain() } + window.addEventListener(STORY_VISUAL_GENERATION_EVENT, handler) + void drain() + return () => { active = false; window.removeEventListener(STORY_VISUAL_GENERATION_EVENT, handler) } +} + +export type AgentSceneControlRequest = + | { type: 'open_3d_scene'; sceneName: string; layerName: string } + | { type: 'save_3d_scene'; sceneName: string } + | { type: 'export_3d_scene'; sceneName: string } + +export type AgentSceneWorkflowRequest = + | { type: 'create_3d_scene'; sceneName: string; durationSeconds: number; width: number; height: number; fps: 30 | 60; reset?: boolean } + | { type: 'set_3d_scene_properties'; sceneName: string; durationSeconds?: number; width?: number; height?: number; fps?: 30 | 60 } + | { type: 'add_3d_scene_layer'; sceneName: string; layerName: string; layerType: 'model3d' | 'image' | 'video' | 'overlay' | 'camera'; outputName?: string } + | { type: 'update_3d_scene_layer'; sceneName: string; layerName: string; visible?: boolean; locked?: boolean } + | { type: 'remove_3d_scene_layer'; sceneName: string; layerName: string } + | { type: 'attach_3d_scene_audio'; sceneName: string; audioOutputName: string } + | { type: 'analyze_3d_scene_audio'; sceneName: string; audioOutputName: string } + | { type: 'apply_3d_choreography'; sceneName: string; layerName: string; audioOutputName: string; cueSource: 'beats' | 'downbeats'; profile: 'pulse' | 'bounce' | 'peek' | 'camera-punch'; intensity: number; rhythmGrid?: AgentRhythmGrid } + | AgentSceneControlRequest + +export interface AgentSceneWorkflowResult { + message: string + sceneId?: string + layerIds?: string[] + audioTrackId?: string + analysisId?: string + bpm?: number + beatCount?: number + downbeatCount?: number + outputNames?: string[] + rhythmGrid?: AgentRhythmGrid +} + +export interface AgentRhythmGrid { + duration: number + bpm: number + beats: Array<{ time: number; strength: number }> + downbeats: number[] +} + +interface PendingSceneWorkflowRequest { + request: AgentSceneWorkflowRequest + resolve: (result: AgentSceneWorkflowResult) => void + reject: (error: Error) => void +} + +const SCENE_WORKFLOW_REQUEST_EVENT = 'hocuspocus:scene-workflow-request' +const pendingSceneWorkflowRequests: PendingSceneWorkflowRequest[] = [] + +export function requestAgentSceneWorkflow(request: AgentSceneWorkflowRequest): Promise { + return new Promise((resolve, reject) => { + pendingSceneWorkflowRequests.push({ request, resolve, reject }) + window.dispatchEvent(new CustomEvent(SCENE_WORKFLOW_REQUEST_EVENT)) + }) +} + +export function listenForAgentSceneWorkflow( + listener: (request: AgentSceneWorkflowRequest) => Promise, +): () => void { + let active = true + const drain = async () => { + while (active && pendingSceneWorkflowRequests.length) { + const pending = pendingSceneWorkflowRequests.shift() + if (!pending) continue + try { pending.resolve(await listener(pending.request)) } + catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } + } + } + const handler = () => { void drain() } + window.addEventListener(SCENE_WORKFLOW_REQUEST_EVENT, handler) + void drain() + return () => { active = false; window.removeEventListener(SCENE_WORKFLOW_REQUEST_EVENT, handler) } +} + +interface PendingSceneControlRequest { + request: AgentSceneControlRequest + resolve: (message: string) => void + reject: (error: Error) => void +} + +const SCENE_CONTROL_REQUEST_EVENT = 'hocuspocus:scene-control-request' +const pendingSceneControlRequests: PendingSceneControlRequest[] = [] + +export function requestAgentSceneControl(request: AgentSceneControlRequest): Promise { + return new Promise((resolve, reject) => { + pendingSceneControlRequests.push({ request, resolve, reject }) + window.dispatchEvent(new CustomEvent(SCENE_CONTROL_REQUEST_EVENT)) + }) +} + +export function listenForAgentSceneControl( + listener: (request: AgentSceneControlRequest) => Promise, +): () => void { + let active = true + const drain = async () => { + while (active && pendingSceneControlRequests.length) { + const pending = pendingSceneControlRequests.shift() + if (!pending) continue + try { pending.resolve(await listener(pending.request)) } + catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } + } + } + const handler = () => { void drain() } + window.addEventListener(SCENE_CONTROL_REQUEST_EVENT, handler) + void drain() + return () => { active = false; window.removeEventListener(SCENE_CONTROL_REQUEST_EVENT, handler) } +} + +export interface AgentSceneRhythmRequest { + sceneName: string + layerName: string + audioOutputName: string + cueSource: 'beats' | 'downbeats' + profile: 'pulse' | 'bounce' | 'peek' | 'camera-punch' + intensity: number +} + +interface PendingSceneRhythmRequest { + request: AgentSceneRhythmRequest + resolve: (message: string) => void + reject: (error: Error) => void +} + +const SCENE_RHYTHM_REQUEST_EVENT = 'hocuspocus:scene-rhythm-request' +const pendingSceneRhythmRequests: PendingSceneRhythmRequest[] = [] + +export function requestAgentSceneRhythm(request: AgentSceneRhythmRequest): Promise { + return new Promise((resolve, reject) => { + const pending = { request, resolve, reject } + pendingSceneRhythmRequests.push(pending) + window.dispatchEvent(new CustomEvent(SCENE_RHYTHM_REQUEST_EVENT)) + }) +} + +export function listenForAgentSceneRhythm( + listener: (request: AgentSceneRhythmRequest) => Promise, +): () => void { + let active = true + const drain = async () => { + while (active && pendingSceneRhythmRequests.length) { + const pending = pendingSceneRhythmRequests.shift() + if (!pending) continue + try { pending.resolve(await listener(pending.request)) } + catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))) } + } + } + const handler = () => { void drain() } + window.addEventListener(SCENE_RHYTHM_REQUEST_EVENT, handler) + void drain() + return () => { active = false; window.removeEventListener(SCENE_RHYTHM_REQUEST_EVENT, handler) } +} + +export function openAgentStorySection(section: AgentStorySection): void { + requestedStorySection = section + window.dispatchEvent(new CustomEvent(STORY_SECTION_EVENT, { detail: { section } })) +} + +export function openAgentSeriesSection(section: AgentSeriesSection): void { + requestedSeriesSection = section + window.dispatchEvent(new CustomEvent(SERIES_SECTION_EVENT, { detail: { section } })) +} + +export function listenForAgentStorySection( + listener: (section: AgentStorySection) => void, +): () => void { + const handler = (event: Event) => { + const section = (event as CustomEvent<{ section?: AgentStorySection }>).detail?.section + if (section) listener(section) + } + window.addEventListener(STORY_SECTION_EVENT, handler) + if (requestedStorySection) listener(requestedStorySection) + return () => window.removeEventListener(STORY_SECTION_EVENT, handler) +} + +export function listenForAgentSeriesSection( + listener: (section: AgentSeriesSection) => void, +): () => void { + const handler = (event: Event) => { + const section = (event as CustomEvent<{ section?: AgentSeriesSection }>).detail?.section + if (section) listener(section) + } + window.addEventListener(SERIES_SECTION_EVENT, handler) + if (requestedSeriesSection) listener(requestedSeriesSection) + return () => window.removeEventListener(SERIES_SECTION_EVENT, handler) +} + +export function notifyAgentStoryDraft(projectId: string): void { + window.dispatchEvent(new CustomEvent(STORY_DRAFT_EVENT, { detail: { projectId } })) +} + +export function listenForAgentStoryDraft(listener: (projectId: string) => void): () => void { + const handler = (event: Event) => { + const projectId = (event as CustomEvent<{ projectId?: string }>).detail?.projectId + if (projectId) listener(projectId) + } + window.addEventListener(STORY_DRAFT_EVENT, handler) + return () => window.removeEventListener(STORY_DRAFT_EVENT, handler) +} + +export function notifyAgentSeriesPlanJob(job: SeriesJobStatus): void { + requestedSeriesPlanJob = job + window.dispatchEvent(new CustomEvent(SERIES_PLAN_JOB_EVENT, { detail: { job, episodeId: job.episodeId } })) +} + +export function clearAgentSeriesPlanJob(episodeId: string): void { + if (requestedSeriesPlanJob?.episodeId === episodeId) requestedSeriesPlanJob = null + window.dispatchEvent(new CustomEvent(SERIES_PLAN_JOB_EVENT, { detail: { job: null, episodeId } })) +} + +export function getAgentSeriesPlanJob(): SeriesJobStatus | null { + return requestedSeriesPlanJob +} + +export function listenForAgentSeriesPlanJob( + listener: (job: SeriesJobStatus | null, episodeId: string) => void, +): () => void { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ job?: SeriesJobStatus | null; episodeId?: string }>).detail + if (detail?.episodeId) listener(detail.job || null, detail.episodeId) + } + window.addEventListener(SERIES_PLAN_JOB_EVENT, handler) + if (requestedSeriesPlanJob) listener(requestedSeriesPlanJob, requestedSeriesPlanJob.episodeId) + return () => window.removeEventListener(SERIES_PLAN_JOB_EVENT, handler) +} + +export function notifyAgentSeriesRenderJob(job: SeriesJobStatus): void { + requestedSeriesRenderJob = job + window.dispatchEvent(new CustomEvent(SERIES_RENDER_JOB_EVENT, { detail: { job } })) +} + +export function getAgentSeriesRenderJob(): SeriesJobStatus | null { + return requestedSeriesRenderJob +} + +export function listenForAgentSeriesRenderJob(listener: (job: SeriesJobStatus) => void): () => void { + const handler = (event: Event) => { + const job = (event as CustomEvent<{ job?: SeriesJobStatus }>).detail?.job + if (job) listener(job) + } + window.addEventListener(SERIES_RENDER_JOB_EVENT, handler) + if (requestedSeriesRenderJob) listener(requestedSeriesRenderJob) + return () => window.removeEventListener(SERIES_RENDER_JOB_EVENT, handler) +} + +export function notifyAgentSeriesAssemblyJob(job: SeriesAssemblyJob): void { + requestedSeriesAssemblyJob = job + window.dispatchEvent(new CustomEvent(SERIES_ASSEMBLY_JOB_EVENT, { detail: { job } })) +} + +export function getAgentSeriesAssemblyJob(): SeriesAssemblyJob | null { + return requestedSeriesAssemblyJob +} + +export function listenForAgentSeriesAssemblyJob(listener: (job: SeriesAssemblyJob) => void): () => void { + const handler = (event: Event) => { + const job = (event as CustomEvent<{ job?: SeriesAssemblyJob }>).detail?.job + if (job) listener(job) + } + window.addEventListener(SERIES_ASSEMBLY_JOB_EVENT, handler) + if (requestedSeriesAssemblyJob) listener(requestedSeriesAssemblyJob) + return () => window.removeEventListener(SERIES_ASSEMBLY_JOB_EVENT, handler) +} + +export function openAgentSeriesReviewView(view: AgentSeriesReviewView): void { + requestedSeriesReviewView = view + window.dispatchEvent(new CustomEvent(SERIES_REVIEW_VIEW_EVENT, { detail: { view } })) +} + +export function listenForAgentSeriesReviewView(listener: (view: AgentSeriesReviewView) => void): () => void { + const handler = (event: Event) => { + const view = (event as CustomEvent<{ view?: AgentSeriesReviewView }>).detail?.view + if (view) listener(view) + } + window.addEventListener(SERIES_REVIEW_VIEW_EVENT, handler) + if (requestedSeriesReviewView) listener(requestedSeriesReviewView) + return () => window.removeEventListener(SERIES_REVIEW_VIEW_EVENT, handler) +} + +const ACTIVITY_DETAILS_EVENT = 'hocuspocus:activity-details' +let requestedActivityDetails = false + +export function openAgentActivityDetails(): void { + requestedActivityDetails = true + window.dispatchEvent(new CustomEvent(ACTIVITY_DETAILS_EVENT)) +} + +export function listenForAgentActivityDetails(listener: () => void): () => void { + const handler = () => listener() + window.addEventListener(ACTIVITY_DETAILS_EVENT, handler) + if (requestedActivityDetails) listener() + return () => window.removeEventListener(ACTIVITY_DETAILS_EVENT, handler) +} diff --git a/ui/tests/agentCapabilityPorts.test.mjs b/ui/tests/agentCapabilityPorts.test.mjs index 82fdfdc5..f3a4cc78 100644 --- a/ui/tests/agentCapabilityPorts.test.mjs +++ b/ui/tests/agentCapabilityPorts.test.mjs @@ -25,10 +25,6 @@ const SET_STATE_ALLOWLIST = [ ] const SLICE_AGENT_IMPORT_ALLOWLIST = [ - ['series/SeriesEpisodePanel.tsx', '../agent/agentUiBus'], - ['series/SeriesLabPanel.tsx', '../agent/agentUiBus'], - ['series/SeriesReviewPanel.tsx', '../agent/agentUiBus'], - ['stories/StoryLabPanel.tsx', '../agent/agentUiBus'], ] const LEGACY_EXECUTE_ALLOWLIST = [ @@ -173,14 +169,13 @@ test('useStore.setState in features/agent stays on the named allowlist outside a assert.equal(actual.reduce((total, row) => total + row[2], 0), 0) }) -test('other feature slices do not import Agent Mode except the frozen UI-bus listeners', () => { +test('other feature slices do not import Agent Mode', () => { const actual = sliceAgentImports() const { added, removed } = diffLists(actual, SLICE_AGENT_IMPORT_ALLOWLIST) assert.deepEqual( { actual, added, removed }, { actual: SLICE_AGENT_IMPORT_ALLOWLIST, added: [], removed: [] }, - 'Story/Series (and later slices) may keep listening on agentUiBus until that bus moves. ' - + 'They must not import agent domain modules. ' + 'Product slices must not import features/agent. ' + `added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`, ) })