diff --git a/.env.sample b/.env.sample index d0f8b6b9..bbbf57f9 100644 --- a/.env.sample +++ b/.env.sample @@ -4,7 +4,7 @@ MONGODB_URI=${MONGODB_URI:-mongodb://api:password@localhost:27017/live-gui} # Ateliere Live System Controlleer LIVE_URL=${LIVE_URL:-https://localhost:8080} LIVE_CREDENTIALS=${LIVE_CREDENTIALS:-admin:admin} -CONTROL_PANEL_WS=${ip/hostname:port} + # This ENV variable disables SSL Verification, use if the above LIVE_URL doesn't have a proper certificate NODE_TLS_REJECT_UNAUTHORIZED=${NODE_TLS_REJECT_UNAUTHORIZED:-1} @@ -15,6 +15,3 @@ BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS:-10} # i18n UI_LANG=${UI_LANG:-en} - -# Mediaplayer - path on the system controller -MEDIAPLAYER_PLACEHOLDER=/media/media_placeholder.mp4 \ No newline at end of file diff --git a/src/api/ateliereLive/pipelines/renderingengine/renderingengine.ts b/src/api/ateliereLive/pipelines/renderingengine/renderingengine.ts new file mode 100644 index 00000000..68507b9d --- /dev/null +++ b/src/api/ateliereLive/pipelines/renderingengine/renderingengine.ts @@ -0,0 +1,539 @@ +import { + ResourcesHTMLBrowser, + ResourcesMediaPlayer, + ResourcesRenderingEngineResponse +} from '../../../../../types/ateliere-live'; +import { LIVE_BASE_API_PATH } from '../../../../constants'; +import { MultiviewSettings } from '../../../../interfaces/multiview'; +import { Production } from '../../../../interfaces/production'; +import { + HTMLSource, + MediaSource +} from '../../../../interfaces/renderingEngine'; +import { SourceReference } from '../../../../interfaces/Source'; +import { Log } from '../../../logger'; +import { getAuthorizationHeader } from '../../utils/authheader'; +import { + getMultiviewsForPipeline, + updateMultiviewForPipeline +} from '../multiviews/multiviews'; + +export async function getPipelineHtmlSources( + pipelineUuid: string +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/pipelines/${pipelineUuid}/renderingengine/html`, + process.env.LIVE_URL + ), + { + method: 'GET', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + } + } + ); + if (response.ok) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + throw await response.json(); +} + +export async function createPipelineHtmlSource( + production: Production, + inputSlot: number, + data: HTMLSource, + source: SourceReference +) { + try { + const { production_settings } = production; + const htmlResults = []; + + for (let i = 0; i < production_settings.pipelines.length; i++) { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${production_settings.pipelines[i].pipeline_id}/renderingengine/html`, + process.env.LIVE_URL + ), + { + method: 'POST', + headers: { + authorization: getAuthorizationHeader() + }, + body: JSON.stringify({ + height: Number(data.height), + input_slot: Number(inputSlot), + url: data.url || '', + width: Number(data.width) + }) + } + ); + + if (response.ok) { + const text = await response.text(); + const jsonResponse = text ? JSON.parse(text) : {}; + htmlResults.push(jsonResponse); + } else { + throw await response.json(); + } + } + } catch (e) { + Log().error('Could not add html'); + Log().error(e); + if (typeof e !== 'string') { + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_html', + success: false + } + ] + }, + error: 'Could not add html' + }; + } + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_html', + success: false, + message: e + } + ] + }, + error: e + }; + } + + try { + if (!production.production_settings.pipelines[0].pipeline_id) { + Log().error( + `Missing pipeline_id for: ${production.production_settings.pipelines[0].pipeline_name}` + ); + throw `Missing pipeline_id for: ${production.production_settings.pipelines[0].pipeline_name}`; + } + const multiviewsResponse = await getMultiviewsForPipeline( + production.production_settings.pipelines[0].pipeline_id + ); + + const multiviews = multiviewsResponse.filter((multiview) => { + const pipeline = production.production_settings.pipelines[0]; + const multiviewArray = pipeline.multiviews; + + if (Array.isArray(multiviewArray)) { + return multiviewArray.some( + (item) => item.multiview_id === multiview.id + ); + } else if (multiviewArray) { + return ( + (multiviewArray as MultiviewSettings).multiview_id === multiview.id + ); + } + + return false; + }); + + if (multiviews.length === 0 || !multiviews) { + Log().error( + `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}` + ); + throw `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}`; + } + + await Promise.all( + multiviews.map(async (multiview) => { + const views = multiview.layout.views; + const viewsForSource = views.filter( + (view) => view.input_slot === inputSlot + ); + if (!viewsForSource || viewsForSource.length === 0) { + Log().info( + `No view found for input slot: ${inputSlot}. Will not connect source to view` + ); + return { + ok: true, + value: { + success: true, + steps: [ + { + step: 'add_html', + success: true + }, + { + step: 'update_multiview', + success: true + } + ] + } + }; + } + const updatedViewsForSource = viewsForSource.map((v) => { + return { ...v, label: source.label }; + }); + + const updatedViews = [ + ...views.filter((view) => view.input_slot !== inputSlot), + ...updatedViewsForSource + ]; + + await updateMultiviewForPipeline( + production.production_settings.pipelines[0].pipeline_id!, + multiview.id, + updatedViews + ); + }) + ); + } catch (e) { + Log().error('Could not update multiview'); + Log().error(e); + if (typeof e !== 'string') { + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_html', + success: true + }, + { + step: 'update_multiview', + success: false + } + ] + }, + error: 'Could not update multiview' + }; + } + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_html', + success: true + }, + { + step: 'update_multiview', + success: false, + message: e + } + ] + }, + error: e + }; + } + + return { + ok: true, + value: { + success: true, + steps: [ + { + step: 'add_html', + success: true + }, + { + step: 'update_multiview', + success: true + } + ] + } + }; +} + +export async function deleteHtmlFromPipeline( + pipelineUuid: string, + inputSlot: number +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${pipelineUuid}/renderingengine/html/${inputSlot}`, + process.env.LIVE_URL + ), + { + method: 'DELETE', + headers: { + authorization: getAuthorizationHeader() + } + } + ); + if (response.ok) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + throw await response.json(); +} + +export async function getPipelineMediaSources( + pipelineUuid: string +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/pipelines/${pipelineUuid}/renderingengine/media`, + process.env.LIVE_URL + ), + { + method: 'GET', + headers: { + authorization: getAuthorizationHeader() + }, + next: { + revalidate: 0 + } + } + ); + if (response.ok) { + return await response.json(); + } + throw await response.json(); +} + +export async function createPipelineMediaSource( + production: Production, + inputSlot: number, + data: MediaSource, + source: SourceReference +) { + try { + const { production_settings } = production; + const mediaResults = []; + + for (let i = 0; i < production_settings.pipelines.length; i++) { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${production_settings.pipelines[i].pipeline_id}/renderingengine/media`, + process.env.LIVE_URL + ), + { + method: 'POST', + headers: { + authorization: getAuthorizationHeader() + }, + body: JSON.stringify({ + filename: data.filename, + input_slot: Number(inputSlot) + }) + } + ); + if (response.ok) { + const text = await response.text(); + const jsonResponse = text ? JSON.parse(text) : {}; + mediaResults.push(jsonResponse); + } else { + throw await response.json(); + } + } + } catch (e) { + Log().error('Could not add media'); + Log().error(e); + if (typeof e !== 'string') { + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_media', + success: false + } + ] + }, + error: 'Could not add media' + }; + } + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_media', + success: false, + message: e + } + ] + }, + error: e + }; + } + + try { + if (!production.production_settings.pipelines[0].pipeline_id) { + Log().error( + `Missing pipeline_id for: ${production.production_settings.pipelines[0].pipeline_name}` + ); + throw `Missing pipeline_id for: ${production.production_settings.pipelines[0].pipeline_name}`; + } + const multiviewsResponse = await getMultiviewsForPipeline( + production.production_settings.pipelines[0].pipeline_id + ); + + const multiviews = multiviewsResponse.filter((multiview) => { + const pipeline = production.production_settings.pipelines[0]; + const multiviewArray = pipeline.multiviews; + + if (Array.isArray(multiviewArray)) { + return multiviewArray.some( + (item) => item.multiview_id === multiview.id + ); + } else if (multiviewArray) { + return ( + (multiviewArray as MultiviewSettings).multiview_id === multiview.id + ); + } + + return false; + }); + + if (multiviews.length === 0 || !multiviews) { + Log().error( + `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}` + ); + throw `No multiview found for pipeline: ${production.production_settings.pipelines[0].pipeline_id}`; + } + + await Promise.all( + multiviews.map(async (multiview) => { + const views = multiview.layout.views; + const viewsForSource = views.filter( + (view) => view.input_slot === inputSlot + ); + if (!viewsForSource || viewsForSource.length === 0) { + Log().info( + `No view found for input slot: ${inputSlot}. Will not connect source to view` + ); + return { + ok: true, + value: { + success: true, + steps: [ + { + step: 'add_media', + success: true + }, + { + step: 'update_multiview', + success: true + } + ] + } + }; + } + const updatedViewsForSource = viewsForSource.map((v) => { + return { ...v, label: source.label }; + }); + + const updatedViews = [ + ...views.filter((view) => view.input_slot !== inputSlot), + ...updatedViewsForSource + ]; + + await updateMultiviewForPipeline( + production.production_settings.pipelines[0].pipeline_id!, + multiview.id, + updatedViews + ); + }) + ); + } catch (e) { + Log().error('Could not update multiview'); + Log().error(e); + if (typeof e !== 'string') { + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_media', + success: true + }, + { + step: 'update_multiview', + success: false + } + ] + }, + error: 'Could not update multiview' + }; + } + return { + ok: false, + value: { + success: false, + steps: [ + { + step: 'add_media', + success: true + }, + { + step: 'update_multiview', + success: false, + message: e + } + ] + }, + error: e + }; + } +} + +export async function deleteMediaFromPipeline( + pipelineUuid: string, + inputSlot: number +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + + `/pipelines/${pipelineUuid}/renderingengine/media/${inputSlot}`, + process.env.LIVE_URL + ), + { + method: 'DELETE', + headers: { + authorization: getAuthorizationHeader() + } + } + ); + if (response.ok) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + throw await response.json(); +} + +export async function getPipelineRenderingEngine( + pipelineUuid: string +): Promise { + const response = await fetch( + new URL( + LIVE_BASE_API_PATH + `/pipelines/${pipelineUuid}/renderingengine`, + process.env.LIVE_URL + ), + { + method: 'GET', + headers: { + authorization: getAuthorizationHeader() + } + } + ); + if (response.ok) { + return await response.json(); + } + throw await response.json(); +} diff --git a/src/api/ateliereLive/websocket.ts b/src/api/ateliereLive/websocket.ts deleted file mode 100644 index 17ac7849..00000000 --- a/src/api/ateliereLive/websocket.ts +++ /dev/null @@ -1,35 +0,0 @@ -import WebSocket from 'ws'; - -function createWebSocket(): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://${process.env.CONTROL_PANEL_WS}`); - ws.on('error', reject); - ws.on('open', () => { - resolve(ws); - }); - }); -} - -export async function createControlPanelWebSocket() { - const ws = await createWebSocket(); - return { - createHtml: (input: number) => { - ws.send(`html create ${input} 1920 1080`); - }, - createMediaplayer: (input: number) => { - ws.send(`media create ${input} ${process.env.MEDIAPLAYER_PLACEHOLDER}`); - }, - closeHtml: (input: number) => { - ws.send(`html close ${input}`); - ws.send('html reset'); - }, - closeMediaplayer: (input: number) => { - ws.send(`media close ${input}`); - ws.send('media reset'); - }, - close: () => - setTimeout(() => { - ws.close(); - }, 1000) - }; -} diff --git a/src/api/manager/productions.ts b/src/api/manager/productions.ts index 34592553..e13c7301 100644 --- a/src/api/manager/productions.ts +++ b/src/api/manager/productions.ts @@ -2,6 +2,7 @@ import { Db, ObjectId, UpdateResult } from 'mongodb'; import { getDatabase } from '../mongoClient/dbClient'; import { Production, ProductionWithId } from '../../interfaces/production'; import { Log } from '../logger'; +import { SourceReference } from '../../interfaces/Source'; export async function getProductions(): Promise { const db = await getDatabase(); @@ -42,7 +43,14 @@ export async function putProduction( _id: newSourceId, type: singleSource.type, label: singleSource.label, - input_slot: singleSource.input_slot + input_slot: singleSource.input_slot, + html_data: + (singleSource.type === 'html' && singleSource.html_data) || + undefined, + media_data: + (singleSource.type === 'mediaplayer' && + singleSource.media_data) || + undefined }; }) : []; diff --git a/src/api/manager/workflow.ts b/src/api/manager/workflow.ts index d0946aea..49074d5d 100644 --- a/src/api/manager/workflow.ts +++ b/src/api/manager/workflow.ts @@ -52,9 +52,15 @@ import { Result } from '../../interfaces/result'; import { Monitoring } from '../../interfaces/monitoring'; import { getDatabase } from '../mongoClient/dbClient'; import { updatedMonitoringForProduction } from './job/syncMonitoring'; -import { createControlPanelWebSocket } from '../ateliereLive/websocket'; import { ObjectId } from 'mongodb'; import { MultiviewSettings } from '../../interfaces/multiview'; +import { + getPipelineRenderingEngine, + createPipelineHtmlSource, + createPipelineMediaSource, + deleteHtmlFromPipeline, + deleteMediaFromPipeline +} from '../ateliereLive/pipelines/renderingengine/renderingengine'; const isUsed = (pipeline: ResourcesPipelineResponse) => { const hasStreams = pipeline.streams.length > 0; @@ -328,46 +334,36 @@ export async function stopProduction( (p) => p.pipeline_id ); - const htmlSources = production.sources.filter( - (source) => source.type === 'html' - ); - const mediaPlayerSources = production.sources.filter( - (source) => source.type === 'mediaplayer' - ); + for (const pipeline of production.production_settings.pipelines) { + const pipelineId = pipeline.pipeline_id; + if (pipelineId) { + const pipelineRenderingEngine = await getPipelineRenderingEngine( + pipelineId + ); - if (htmlSources.length > 0 || mediaPlayerSources.length > 0) { - let controlPanelWS; - try { - controlPanelWS = await createControlPanelWebSocket(); + const htmlSources = pipelineRenderingEngine.html; + const mediaSources = pipelineRenderingEngine.media; - for (const source of htmlSources) { - controlPanelWS.closeHtml(source.input_slot); + if (htmlSources.length > 0 && htmlSources) { + for (const pipeline of production.production_settings.pipelines) { + for (const htmlSource of htmlSources) { + const pipelineId = pipeline.pipeline_id; + if (pipelineId !== undefined) { + await deleteHtmlFromPipeline(pipelineId, htmlSource.input_slot); + } + } + } } - for (const source of mediaPlayerSources) { - controlPanelWS.closeMediaplayer(source.input_slot); - } - } catch (error) { - Log().error( - 'Failed to create WebSocket or perform operations during stopProduction' - ); - Log().error(error); - return { - ok: false, - value: [ - { - step: 'websocket', - success: false, - message: - 'Failed to create WebSocket or perform operations during stopProduction' + if (mediaSources.length > 0 && mediaSources) { + for (const pipeline of production.production_settings.pipelines) { + for (const mediaSource of mediaSources) { + const pipelineId = pipeline.pipeline_id; + if (pipelineId !== undefined) { + await deleteMediaFromPipeline(pipelineId, mediaSource.input_slot); + } } - ], - error: - 'Failed to create WebSocket or perform operations during stopProduction' - }; - } finally { - if (controlPanelWS) { - controlPanelWS.close(); + } } } } @@ -686,47 +682,6 @@ export async function startProduction( }; } // Try to connect control panels and pipeline-to-pipeline connections end - const htmlSources = production.sources.filter( - (source) => source.type === 'html' - ); - const mediaPlayerSources = production.sources.filter( - (source) => source.type === 'mediaplayer' - ); - - if (htmlSources.length > 0 || mediaPlayerSources.length > 0) { - let controlPanelWS; - try { - controlPanelWS = await createControlPanelWebSocket(); - - for (const source of htmlSources) { - controlPanelWS.createHtml(source.input_slot); - } - - for (const source of mediaPlayerSources) { - controlPanelWS.createMediaplayer(source.input_slot); - } - } catch (error) { - Log().error('Failed to create WebSocket'); - Log().error(error); - return { - ok: false, - value: [ - { step: 'start', success: true }, - { step: 'streams', success: true }, - { - step: 'websocket', - success: false, - message: `Failed to create websocket: ${error}` - } - ], - error: 'Failed to create WebSocket' - }; - } finally { - if (controlPanelWS) { - controlPanelWS.close(); - } - } - } // Try to setup pipeline outputs start try { for (const pipeline of production_settings.pipelines) { @@ -763,7 +718,9 @@ export async function startProduction( ], error: e }; - } // Try to setup pipeline outputs end + } + + // Try to setup pipeline outputs end // Try to setup multiviews start try { if (!production.production_settings.pipelines[0].multiviews) { @@ -829,6 +786,47 @@ export async function startProduction( }; } // Try to setup multiviews end + // Create HTML and Media sources on each pipeline + const htmlSources = production.sources.filter( + (source) => source.type === 'html' + ); + const mediaSources = production.sources.filter( + (source) => source.type === 'mediaplayer' + ); + + if (htmlSources.length > 0) { + for (const htmlSource of htmlSources) { + if (htmlSource.html_data) { + const htmlData = { + ...htmlSource.html_data, + url: htmlSource.html_data?.url || '', + input_slot: htmlSource.input_slot + }; + await createPipelineHtmlSource( + production, + htmlSource.input_slot, + htmlData, + htmlSource + ); + } + } + } + + if (mediaSources.length > 0) { + for (const mediaSource of mediaSources) { + const mediaData = { + filename: mediaSource.media_data?.filename || '', + input_slot: mediaSource.input_slot + }; + await createPipelineMediaSource( + production, + mediaSource.input_slot, + mediaData, + mediaSource + ); + } + } + try { const sourceIds = production.sources .filter( diff --git a/src/app/api/manager/pipelines/[id]/rendering-engine/html/[input_slot]/route.ts b/src/app/api/manager/pipelines/[id]/rendering-engine/html/[input_slot]/route.ts new file mode 100644 index 00000000..8e97c1ff --- /dev/null +++ b/src/app/api/manager/pipelines/[id]/rendering-engine/html/[input_slot]/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../../../../api/manager/auth'; +import { deleteHtmlFromPipeline } from '../../../../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { MultiviewSettings } from '../../../../../../../../interfaces/multiview'; +import { updateMultiviewForPipeline } from '../../../../../../../../api/ateliereLive/pipelines/multiviews/multiviews'; +import { DeleteRenderingEngineSourceStep } from '../../../../../../../../interfaces/Source'; +import { Result } from '../../../../../../../../interfaces/result'; +import { Log } from '../../../../../../../../api/logger'; + +type Params = { + id: string; + input_slot: number; +}; + +export async function DELETE( + request: NextRequest, + { params }: { params: Params } +) { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + const body = await request.json(); + const multiview = body.multiview as MultiviewSettings[]; + + try { + await deleteHtmlFromPipeline(params.id, params.input_slot).catch((e) => { + Log().error(`Failed to delete html: ${params.id}: ${e.message}`); + throw `Failed to delete html: ${params.id}: ${e.message}`; + }); + if (!multiview || multiview.length === 0) { + return new NextResponse( + JSON.stringify({ + ok: true, + value: [ + { + step: 'delete_html', + success: true + } + ] + }) + ); + } + } catch (e) { + if (typeof e !== 'string') { + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_html', + success: false, + message: `Failed to delete html` + } + ], + error: 'Delete html failed' + } satisfies Result) + ); + } + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_html', + success: false, + message: `Failed to delete html: ${params.id}: ${e}` + } + ], + error: e + } satisfies Result) + ); + } + + try { + const multiviewUpdates = multiview.map(async (singleMultiview) => { + if (!singleMultiview.multiview_id) { + throw `The provided multiview settings did not contain any multiview id`; + } + return updateMultiviewForPipeline( + params.id, + singleMultiview.multiview_id, + singleMultiview.layout.views + ).catch((e) => { + throw `Error when updating multiview: ${e.message}`; + }); + }); + + await Promise.all(multiviewUpdates); + + return new NextResponse( + JSON.stringify({ + ok: true, + value: [ + { + step: 'delete_html', + success: true + }, + { + step: 'update_multiview', + success: true + } + ] + } satisfies Result) + ); + } catch (e) { + if (typeof e !== 'string') { + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_html', + success: true + }, + { + step: 'update_multiview', + success: false, + message: `Failed to update multiview` + } + ], + error: 'Failed to update multiview' + } satisfies Result) + ); + } + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_html', + success: true + }, + { + step: 'update_multiview', + success: false, + message: `Failed to update multiview: ${e}` + } + ], + error: e + } satisfies Result) + ); + } +} diff --git a/src/app/api/manager/pipelines/[id]/rendering-engine/html/get/route.ts b/src/app/api/manager/pipelines/[id]/rendering-engine/html/get/route.ts new file mode 100644 index 00000000..c4d57cf6 --- /dev/null +++ b/src/app/api/manager/pipelines/[id]/rendering-engine/html/get/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../../../../api/manager/auth'; +import { getPipelineHtmlSources } from '../../../../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; + +type Params = { + id: string; +}; + +export async function GET( + request: NextRequest, + { params }: { params: Params } +) { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + try { + const htmlSources = await getPipelineHtmlSources(params.id); + return new NextResponse( + JSON.stringify({ + htmlSources + }), + { + status: 200 + } + ); + } catch (error) { + console.log(error); + return new NextResponse( + `Error fetching pipeline html sources! Error: ${error}`, + { + status: 500 + } + ); + } +} diff --git a/src/app/api/manager/pipelines/[id]/rendering-engine/media/[input_slot]/route.ts b/src/app/api/manager/pipelines/[id]/rendering-engine/media/[input_slot]/route.ts new file mode 100644 index 00000000..01e595c3 --- /dev/null +++ b/src/app/api/manager/pipelines/[id]/rendering-engine/media/[input_slot]/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../../../../api/manager/auth'; +import { deleteMediaFromPipeline } from '../../../../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { MultiviewSettings } from '../../../../../../../../interfaces/multiview'; +import { updateMultiviewForPipeline } from '../../../../../../../../api/ateliereLive/pipelines/multiviews/multiviews'; +import { DeleteRenderingEngineSourceStep } from '../../../../../../../../interfaces/Source'; +import { Result } from '../../../../../../../../interfaces/result'; +import { Log } from '../../../../../../../../api/logger'; + +type Params = { + id: string; + input_slot: number; +}; + +export async function DELETE( + request: NextRequest, + { params }: { params: Params } +) { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + const body = await request.json(); + const multiview = body.multiview as MultiviewSettings[]; + + try { + await deleteMediaFromPipeline(params.id, params.input_slot).catch((e) => { + Log().error(`Failed to delete media: ${params.id}: ${e.message}`); + throw `Failed to delete media: ${params.id}: ${e.message}`; + }); + if (!multiview || multiview.length === 0) { + return new NextResponse( + JSON.stringify({ + ok: true, + value: [ + { + step: 'delete_media', + success: true + } + ] + }) + ); + } + } catch (e) { + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_media', + success: false, + message: `Failed to delete media` + } + ], + error: typeof e === 'string' ? e : 'Failed to delete media' + } satisfies Result) + ); + } + try { + const multiviewUpdates = multiview.map(async (singleMultiview) => { + if (!singleMultiview.multiview_id) { + throw `The provided multiview settings did not contain any multiview id`; + } + return updateMultiviewForPipeline( + params.id, + singleMultiview.multiview_id, + singleMultiview.layout.views + ).catch((e) => { + throw `Error when updating multiview: ${e.message}`; + }); + }); + + await Promise.all(multiviewUpdates); + + return new NextResponse( + JSON.stringify({ + ok: true, + value: [ + { + step: 'delete_media', + success: true + }, + { + step: 'update_multiview', + success: true + } + ] + } satisfies Result) + ); + } catch (e) { + return new NextResponse( + JSON.stringify({ + ok: false, + value: [ + { + step: 'delete_media', + success: true + }, + { + step: 'update_multiview', + success: false, + message: `Failed to update multiview: ${e}` + } + ], + error: typeof e === 'string' ? e : 'Failed to update multiview' + } satisfies Result) + ); + } +} diff --git a/src/app/api/manager/pipelines/[id]/rendering-engine/media/get/route.ts b/src/app/api/manager/pipelines/[id]/rendering-engine/media/get/route.ts new file mode 100644 index 00000000..63dcd761 --- /dev/null +++ b/src/app/api/manager/pipelines/[id]/rendering-engine/media/get/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../../../../api/manager/auth'; +import { getPipelineMediaSources } from '../../../../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { Log } from '../../../../../../../../api/logger'; + +type Params = { + id: string; +}; + +export async function GET( + request: NextRequest, + { params }: { params: Params } +) { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + try { + const mediaSources = await getPipelineMediaSources(params.id); + return new NextResponse( + JSON.stringify({ + mediaSources + }), + { + status: 200 + } + ); + } catch (error) { + Log().error(error); + return new NextResponse( + `Error fetching pipeline media sources! Error: ${error}`, + { + status: 500 + } + ); + } +} diff --git a/src/app/api/manager/pipelines/[id]/rendering-engine/route.ts b/src/app/api/manager/pipelines/[id]/rendering-engine/route.ts new file mode 100644 index 00000000..ae80d825 --- /dev/null +++ b/src/app/api/manager/pipelines/[id]/rendering-engine/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../../api/manager/auth'; +import { getPipelineRenderingEngine } from '../../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { Log } from '../../../../../../api/logger'; + +type Params = { + id: string; +}; + +export async function GET( + request: NextRequest, + { params }: { params: Params } +) { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + try { + const renderingEngine = await getPipelineRenderingEngine(params.id); + return new NextResponse( + JSON.stringify({ + renderingEngine + }), + { + status: 200 + } + ); + } catch (error) { + Log().error(error); + return new NextResponse( + `Error getting rendering engine for pipeline! Error: ${error}`, + { + status: 500 + } + ); + } +} diff --git a/src/app/api/manager/rendering-engine/html/route.ts b/src/app/api/manager/rendering-engine/html/route.ts new file mode 100644 index 00000000..e643fbbe --- /dev/null +++ b/src/app/api/manager/rendering-engine/html/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../api/manager/auth'; +import { createPipelineHtmlSource } from '../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { Log } from '../../../../../api/logger'; +import { HTMLSource } from '../../../../../interfaces/renderingEngine'; +import { Production } from '../../../../../interfaces/production'; +import { SourceReference } from '../../../../../interfaces/Source'; + +export type CreateHtmlRequestBody = { + production: Production; + htmlBody: HTMLSource; + inputSlot: number; + source: SourceReference; +}; + +export async function POST(request: NextRequest): Promise { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + const data = await request.json(); + const createHtmlRequest = data as CreateHtmlRequestBody; + + return await createPipelineHtmlSource( + createHtmlRequest.production, + createHtmlRequest.inputSlot, + createHtmlRequest.htmlBody, + createHtmlRequest.source + ) + .then((response) => { + return new NextResponse(JSON.stringify(response)); + }) + .catch((error) => { + Log().error(error); + const errorResponse = { + ok: false, + error: error + }; + return new NextResponse(JSON.stringify(errorResponse), { status: 500 }); + }); +} diff --git a/src/app/api/manager/rendering-engine/media/route.ts b/src/app/api/manager/rendering-engine/media/route.ts new file mode 100644 index 00000000..f21e1c5a --- /dev/null +++ b/src/app/api/manager/rendering-engine/media/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isAuthenticated } from '../../../../../api/manager/auth'; +import { createPipelineMediaSource } from '../../../../../api/ateliereLive/pipelines/renderingengine/renderingengine'; +import { Log } from '../../../../../api/logger'; +import { MediaSource } from '../../../../../interfaces/renderingEngine'; +import { Production } from '../../../../../interfaces/production'; +import { SourceReference } from '../../../../../interfaces/Source'; + +export type CreateMediaRequestBody = { + production: Production; + mediaBody: MediaSource; + inputSlot: number; + source: SourceReference; +}; + +export async function POST(request: NextRequest): Promise { + if (!(await isAuthenticated())) { + return new NextResponse(`Not Authorized!`, { + status: 403 + }); + } + + const data = await request.json(); + const createMediaRequest = data as CreateMediaRequestBody; + + return await createPipelineMediaSource( + createMediaRequest.production, + createMediaRequest.inputSlot, + createMediaRequest.mediaBody, + createMediaRequest.source + ) + .then((response) => { + return new NextResponse(JSON.stringify(response)); + }) + .catch((error) => { + Log().error(error); + const errorResponse = { + ok: false, + error: error + }; + return new NextResponse(JSON.stringify(errorResponse), { status: 500 }); + }); +} diff --git a/src/app/api/manager/websocket/route.ts b/src/app/api/manager/websocket/route.ts deleted file mode 100644 index 1ff9789c..00000000 --- a/src/app/api/manager/websocket/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextResponse, NextRequest } from 'next/server'; - -const wsUrl = `ws://${process.env.CONTROL_PANEL_WS}`; - -export async function POST(request: NextRequest): Promise { - const { action, inputSlot } = await request.json(); - - if (!wsUrl) { - return NextResponse.json( - { message: 'WebSocket URL is not defined' }, - { status: 500 } - ); - } - - return new Promise((resolve) => { - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - if (action === 'closeHtml') { - ws.send(`html close ${inputSlot}`); - ws.send('html reset'); - } else if (action === 'closeMediaplayer') { - ws.send(`media close ${inputSlot}`); - ws.send('media reset'); - } - ws.close(); - }; - - ws.onerror = (error) => { - resolve( - NextResponse.json( - { message: 'WebSocket error', error }, - { status: 500 } - ) - ); - }; - }); -} diff --git a/src/app/production/[id]/page.tsx b/src/app/production/[id]/page.tsx index 0b6f0927..e007560c 100644 --- a/src/app/production/[id]/page.tsx +++ b/src/app/production/[id]/page.tsx @@ -13,8 +13,7 @@ import { AddSourceStatus, DeleteSourceStatus, SourceReference, - SourceWithId, - Type + SourceWithId } from '../../../interfaces/Source'; import { useGetProduction, usePutProduction } from '../../../hooks/productions'; import { Production } from '../../../interfaces/production'; @@ -48,7 +47,6 @@ import { GlobalContext } from '../../../contexts/GlobalContext'; import { Select } from '../../../components/select/Select'; import { useAddSource } from '../../../hooks/sources/useAddSource'; import { useGetFirstEmptySlot } from '../../../hooks/useGetFirstEmptySlot'; -import { useWebsocket } from '../../../hooks/useWebsocket'; import { ConfigureMultiviewButton } from '../../../components/modal/configureMultiviewModal/ConfigureMultiviewButton'; import { useUpdateSourceInputSlotOnMultiviewLayouts } from '../../../hooks/useUpdateSourceInputSlotOnMultiviewLayouts'; import { useCheckProductionPipelines } from '../../../hooks/useCheckProductionPipelines'; @@ -63,6 +61,13 @@ import { useUpdateMultiviewersOnRunningProduction } from '../../../hooks/workflow'; import { MultiviewSettings } from '../../../interfaces/multiview'; +import { CreateHtmlModal } from '../../../components/modal/renderingEngineModals/CreateHtmlModal'; +import { CreateMediaModal } from '../../../components/modal/renderingEngineModals/CreateMediaModal'; +import { useDeleteHtmlSource } from '../../../hooks/renderingEngine/useDeleteHtmlSource'; +import { useDeleteMediaSource } from '../../../hooks/renderingEngine/useDeleteMediaSource'; +import { useCreateHtmlSource } from '../../../hooks/renderingEngine/useCreateHtmlSource'; +import { useCreateMediaSource } from '../../../hooks/renderingEngine/useCreateMediaSource'; +import { useRenderingEngine } from '../../../hooks/renderingEngine/useRenderingEngine'; export default function ProductionConfiguration({ params }: PageProps) { const t = useTranslate(); @@ -118,14 +123,13 @@ export default function ProductionConfiguration({ params }: PageProps) { const [addSourceStatus, setAddSourceStatus] = useState(); const [deleteSourceStatus, setDeleteSourceStatus] = useState(); + const [isHtmlModalOpen, setIsHtmlModalOpen] = useState(false); + const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); // Create source const [firstEmptySlot] = useGetFirstEmptySlot(); const [addSource] = useAddSource(); - // Websocket - const [closeWebsocket] = useWebsocket(); - const [updateStream, loading] = useUpdateStream(); const [getIngestSourceId, ingestSourceIdLoading] = useIngestSourceId(); @@ -134,36 +138,25 @@ export default function ProductionConfiguration({ params }: PageProps) { const [checkProductionPipelines] = useCheckProductionPipelines(); + // Rendering engine + const [deleteHtmlSource, deleteHtmlLoading] = useDeleteHtmlSource(); + const [deleteMediaSource, deleteMediaLoading] = useDeleteMediaSource(); + const [createHtmlSource, createHtmlLoading] = useCreateHtmlSource(); + const [createMediaSource, createMediaLoading] = useCreateMediaSource(); + const [getRenderingEngine, renderingEngineLoading] = useRenderingEngine(); + const { locked } = useContext(GlobalContext); const memoizedProduction = useMemo(() => productionSetup, [productionSetup]); const isAddButtonDisabled = - selectedValue !== 'HTML' && selectedValue !== 'Media Player'; + (selectedValue !== 'HTML' && selectedValue !== 'Media Player') || locked; useEffect(() => { refreshPipelines(); refreshControlPanels(); }, [productionSetup?.isActive]); - const addSourceToProduction = (type: Type) => { - const input: SourceReference = { - type: type, - label: type === 'html' ? 'HTML Input' : 'Media Player Source', - input_slot: firstEmptySlot(productionSetup) - }; - - if (!productionSetup) return; - addSource(input, productionSetup).then((updatedSetup) => { - if (!updatedSetup) return; - setProductionSetup(updatedSetup); - refreshProduction(); - setAddSourceModal(false); - setSelectedSource(undefined); - }); - setAddSourceStatus(undefined); - }; - useEffect(() => { if (updateMuliviewLayouts && productionSetup) { updateSourceInputSlotOnMultiviewLayouts(productionSetup).then( @@ -545,10 +538,76 @@ export default function ProductionConfiguration({ params }: PageProps) { } }; + const addHtmlSource = (height: number, width: number, url: string) => { + if (productionSetup) { + const sourceToAdd: SourceReference = { + type: 'html', + label: `HTML ${firstEmptySlot(productionSetup)}`, + input_slot: firstEmptySlot(productionSetup), + html_data: { + height: height, + url: url, + width: width + } + }; + const updatedSetup = addSetupItem(sourceToAdd, productionSetup); + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { + refreshProduction(); + }); + + if (productionSetup?.isActive && sourceToAdd.html_data) { + createHtmlSource( + productionSetup, + sourceToAdd.input_slot, + sourceToAdd.html_data, + sourceToAdd + ); + } + } + }; + + const addMediaSource = (filename: string) => { + if (productionSetup) { + const sourceToAdd: SourceReference = { + type: 'mediaplayer', + label: `Media Player ${firstEmptySlot(productionSetup)}`, + input_slot: firstEmptySlot(productionSetup), + media_data: { + filename: filename + } + }; + const updatedSetup = addSetupItem(sourceToAdd, productionSetup); + if (!updatedSetup) return; + setProductionSetup(updatedSetup); + putProduction(updatedSetup._id.toString(), updatedSetup).then(() => { + refreshProduction(); + }); + + if (productionSetup?.isActive && sourceToAdd.media_data) { + createMediaSource( + productionSetup, + sourceToAdd.input_slot, + sourceToAdd.media_data, + sourceToAdd + ); + } + } + }; + const isDisabledFunction = (source: SourceWithId): boolean => { return selectedProductionItems?.includes(source._id.toString()); }; + const handleOpenModal = (type: 'html' | 'media') => { + if (type === 'html') { + setIsHtmlModalOpen(true); + } else if (type === 'media') { + setIsMediaModalOpen(true); + } + }; + const handleAddSource = async () => { setAddSourceStatus(undefined); if ( @@ -615,7 +674,7 @@ export default function ProductionConfiguration({ params }: PageProps) { if (!result.value) { setAddSourceStatus({ success: false, - steps: [{ step: 'unexpected', success: false }] + steps: [{ step: 'add_stream', success: false }] }); } else { setAddSourceStatus({ @@ -662,7 +721,10 @@ export default function ProductionConfiguration({ params }: PageProps) { ) ); - if (selectedSourceRef.stream_uuids) { + if ( + selectedSourceRef.stream_uuids && + selectedSourceRef.stream_uuids.length > 0 + ) { if (!viewToUpdate) { if (!productionSetup.production_settings.pipelines[0].pipeline_id) return; @@ -753,16 +815,30 @@ export default function ProductionConfiguration({ params }: PageProps) { selectedSourceRef.type === 'html' || selectedSourceRef.type === 'mediaplayer' ) { - // Action specifies what websocket method to call - const action = - selectedSourceRef.type === 'html' ? 'closeHtml' : 'closeMediaplayer'; - const inputSlot = productionSetup.sources.find( - (source) => source._id === selectedSourceRef._id - )?.input_slot; - - if (!inputSlot) return; - - closeWebsocket(action, inputSlot); + for ( + let i = 0; + i < productionSetup.production_settings.pipelines.length; + i++ + ) { + const pipelineId = + productionSetup.production_settings.pipelines[i].pipeline_id; + if (pipelineId) { + const renderingEngine = getRenderingEngine(pipelineId); + if (selectedSourceRef.type === 'html') { + await deleteHtmlSource( + pipelineId, + selectedSourceRef.input_slot, + productionSetup + ); + } else if (selectedSourceRef.type === 'mediaplayer') { + await deleteMediaSource( + pipelineId, + selectedSourceRef.input_slot, + productionSetup + ); + } + } + } } const updatedSetup = removeSetupItem(selectedSourceRef, productionSetup); @@ -874,9 +950,9 @@ export default function ProductionConfiguration({ params }: PageProps) { isDisabledFunc={isDisabledFunction} locked={locked} /> - {addSourceModal && selectedSource && ( + {addSourceModal && (selectedSource || selectedSourceRef) && ( )} @@ -958,7 +1038,8 @@ export default function ProductionConfiguration({ params }: PageProps) { classNames="w-full" disabled={ productionSetup?.production_settings === undefined || - productionSetup.production_settings === null + productionSetup.production_settings === null || + locked } options={[ t('production.add_other_source_type'), @@ -977,9 +1058,7 @@ export default function ProductionConfiguration({ params }: PageProps) { : 'bg-zinc-500 text-white' }`} onClick={() => - addSourceToProduction( - selectedValue === 'HTML' ? 'html' : 'mediaplayer' - ) + handleOpenModal(selectedValue === 'HTML' ? 'html' : 'media') } disabled={isAddButtonDisabled} > @@ -1033,6 +1112,18 @@ export default function ProductionConfiguration({ params }: PageProps) { )} + setIsHtmlModalOpen(false)} + onConfirm={addHtmlSource} + loading={createHtmlLoading} + /> + setIsMediaModalOpen(false)} + onConfirm={addMediaSource} + loading={createMediaLoading} + /> ); diff --git a/src/components/dropDown/ControlPanelDropDown.tsx b/src/components/dropDown/ControlPanelDropDown.tsx index 5decaa7a..70d3f8f8 100644 --- a/src/components/dropDown/ControlPanelDropDown.tsx +++ b/src/components/dropDown/ControlPanelDropDown.tsx @@ -33,7 +33,7 @@ export default function ControlPanelDropDown({ } else { setSelected(initial); } - }, [initial]); + }, []); const handleAddSelectedControlPanel = (option: string) => { setSelected((prevState) => { diff --git a/src/components/modal/renderingEngineModals/CreateHtmlModal.tsx b/src/components/modal/renderingEngineModals/CreateHtmlModal.tsx new file mode 100644 index 00000000..0463cfdc --- /dev/null +++ b/src/components/modal/renderingEngineModals/CreateHtmlModal.tsx @@ -0,0 +1,151 @@ +'use client'; +import { useTranslate } from '../../../i18n/useTranslate'; +import { Modal } from '../Modal'; +import Input from '../../input/Input'; +import React, { useEffect, useState } from 'react'; +import { Button } from '../../button/Button'; +import { Loader } from '../../loader/Loader'; + +type CreateHtmlModalProps = { + open: boolean; + loading: boolean; + onAbort: () => void; + onConfirm: (height: number, width: number, url: string) => void; +}; + +export function CreateHtmlModal({ + open, + loading, + onAbort, + onConfirm +}: CreateHtmlModalProps) { + const [height, setHeight] = useState(0); + const [width, setWidth] = useState(0); + const [url, setUrl] = useState(''); + const [heightError, setHeightError] = useState(false); + const [widthError, setWidthError] = useState(false); + + const t = useTranslate(); + + useEffect(() => { + if (height) { + setHeightError(false); + } + }, [height]); + + useEffect(() => { + if (width) { + setWidthError(false); + } + }, [width]); + + const handleCancel = () => { + // Reset all errors + setHeightError(false); + setWidthError(false); + + // Reset all values + setUrl(''); + + onAbort(); + }; + + const handleCreate = () => { + let hasError = false; + + if (!height) { + setHeightError(true); + hasError = true; + } + + if (!width) { + setWidthError(true); + hasError = true; + } + + if (hasError) { + return false; + } + + onConfirm(height, width, url); + handleCancel(); + }; + + const handleInputChange = ( + setter: React.Dispatch>, + errorSetter?: React.Dispatch> + ) => { + return (e: React.ChangeEvent) => { + setter(e.target.value); + if (errorSetter) { + errorSetter(false); + } + }; + }; + + return ( + +

+ {t('rendering_engine.html.create.create_html')} +

+
+ +

+ {t('rendering_engine.html.create.width')}: +

+ + {widthError &&

{t('rendering_engine.html.create.width_error')}

} +
+ +

+ {t('rendering_engine.html.create.height')}: +

+ + {heightError && ( +

{t('rendering_engine.html.create.height_error')}

+ )} +
+ +

+ {t('rendering_engine.html.create.url')}: +

+ +
+
+
+ <> + + {loading ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/components/modal/renderingEngineModals/CreateMediaModal.tsx b/src/components/modal/renderingEngineModals/CreateMediaModal.tsx new file mode 100644 index 00000000..32f70459 --- /dev/null +++ b/src/components/modal/renderingEngineModals/CreateMediaModal.tsx @@ -0,0 +1,79 @@ +'use client'; +import { useTranslate } from '../../../i18n/useTranslate'; +import { Modal } from '../Modal'; +import Input from '../../input/Input'; +import React, { useState } from 'react'; +import { Button } from '../../button/Button'; +import { Loader } from '../../loader/Loader'; + +type CreateMediaModalProps = { + open: boolean; + loading: boolean; + onAbort: () => void; + onConfirm: (filename: string) => void; +}; + +export function CreateMediaModal({ + open, + loading, + onAbort, + onConfirm +}: CreateMediaModalProps) { + const [filename, setFilename] = useState(''); + + const t = useTranslate(); + + const handleCancel = () => { + // Reset all values + setFilename(''); + onAbort(); + }; + + const handleCreate = () => { + if (!filename) { + setFilename(''); + } + onConfirm(filename); + handleCancel(); + }; + + return ( + +

+ {t('rendering_engine.media.create.create_media')} +

+
+ +

+ {t('rendering_engine.media.create.filename')}: +

+ ) => + setFilename(e.target.value) + } + /> +
+
+
+ <> + + {loading ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/components/sourceCard/SourceCard.tsx b/src/components/sourceCard/SourceCard.tsx index c2c4c96f..fc0af485 100644 --- a/src/components/sourceCard/SourceCard.tsx +++ b/src/components/sourceCard/SourceCard.tsx @@ -1,11 +1,5 @@ 'use client'; -import React, { - ChangeEvent, - KeyboardEvent, - useContext, - useRef, - useState -} from 'react'; +import React, { ChangeEvent, KeyboardEvent, useContext, useState } from 'react'; import { IconTrash, IconSettings } from '@tabler/icons-react'; import { SourceReference } from '../../interfaces/Source'; import { useTranslate } from '../../i18n/useTranslate'; @@ -15,7 +9,8 @@ import { getSourceThumbnail } from '../../utils/source'; import { GlobalContext } from '../../contexts/GlobalContext'; import { ConfigureAlignmentLatencyModal } from '../modal/ConfigureAlignmentLatencyModal'; import { Production } from '../../interfaces/production'; -import { useUpdateStream } from '../../hooks/streams'; +import { useDeleteHtmlSource } from '../../hooks/renderingEngine/useDeleteHtmlSource'; +import { useDeleteMediaSource } from '../../hooks/renderingEngine/useDeleteMediaSource'; type SourceCardProps = { source?: ISource; @@ -55,6 +50,9 @@ export default function SourceCard({ const [isAlignmentModalOpen, setIsAlignmentModalOpen] = useState(false); const t = useTranslate(); + const [deleteHtmlSource, deleteHtmlLoading] = useDeleteHtmlSource(); + const [deleteMediaSource, deleteMediaLoading] = useDeleteMediaSource(); + const { locked } = useContext(GlobalContext); const updateText = (event: ChangeEvent) => { @@ -145,28 +143,11 @@ export default function SourceCard({ )} - {(source || sourceRef) && ( + {sourceRef && ( diff --git a/src/components/startProduction/StartProductionFeed.tsx b/src/components/startProduction/StartProductionFeed.tsx index 698f275c..5aa245b8 100644 --- a/src/components/startProduction/StartProductionFeed.tsx +++ b/src/components/startProduction/StartProductionFeed.tsx @@ -18,8 +18,7 @@ export default function StartProductionFeed({ sync: t('start_production_status.sync'), monitoring: t('start_production_status.monitoring'), start: t('start_production_status.start'), - unexpected: t('start_production_status.unexpected'), - websocket: t('start_production_status.websocket') + unexpected: t('start_production_status.unexpected') }; return (
diff --git a/src/components/startProduction/StopProductionFeed.tsx b/src/components/startProduction/StopProductionFeed.tsx index 4660a446..0b61fe4c 100644 --- a/src/components/startProduction/StopProductionFeed.tsx +++ b/src/components/startProduction/StopProductionFeed.tsx @@ -18,8 +18,7 @@ export default function StopProductionFeed({ remove_pipeline_multiviews: t( 'stop_production_status.remove_pipeline_multiviews' ), - unexpected: t('stop_production_status.unexpected'), - websocket: t('stop_production_status.websocket') + unexpected: t('stop_production_status.unexpected') }; return (
diff --git a/src/hooks/items/addSetupItem.ts b/src/hooks/items/addSetupItem.ts index b341c44e..c6d496c8 100644 --- a/src/hooks/items/addSetupItem.ts +++ b/src/hooks/items/addSetupItem.ts @@ -17,7 +17,9 @@ export function addSetupItem( type: source.type, label: source.label, stream_uuids: source.stream_uuids, - input_slot: source.input_slot + input_slot: source.input_slot, + html_data: source.html_data, + media_data: source.media_data } ].sort((a, b) => a.input_slot - b.input_slot) }; @@ -30,7 +32,9 @@ export function addSetupItem( type: source.type, label: source.label, stream_uuids: source.stream_uuids, - input_slot: source.input_slot + input_slot: source.input_slot, + html_data: source.html_data, + media_data: source.media_data } ].sort((a, b) => a.input_slot - b.input_slot) }; diff --git a/src/hooks/renderingEngine/useCreateHtmlSource.tsx b/src/hooks/renderingEngine/useCreateHtmlSource.tsx new file mode 100644 index 00000000..55c67518 --- /dev/null +++ b/src/hooks/renderingEngine/useCreateHtmlSource.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { + AddRenderingEngineSourceResult, + SourceReference +} from '../../interfaces/Source'; +import { Production } from '../../interfaces/production'; +import { CallbackHook } from '../types'; +import { Result } from '../../interfaces/result'; +import { API_SECRET_KEY } from '../../utils/constants'; +import { HTMLSource } from '../../interfaces/renderingEngine'; + +export function useCreateHtmlSource(): CallbackHook< + ( + production: Production, + inputSlot: number, + htmlBody: HTMLSource, + source: SourceReference + ) => Promise> +> { + const [loading, setLoading] = useState(false); + + const createHtmlSource = async ( + production: Production, + inputSlot: number, + htmlBody: HTMLSource, + source: SourceReference + ): Promise> => { + setLoading(true); + + return fetch(`/api/manager/rendering-engine/html`, { + method: 'POST', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], + body: JSON.stringify({ + production: production, + inputSlot: inputSlot, + htmlBody: htmlBody, + source: source + }) + }) + .then(async (response) => { + if (response.ok) { + return response.json(); + } + throw await response.text(); + }) + .finally(() => setLoading(false)); + }; + return [createHtmlSource, loading]; +} diff --git a/src/hooks/renderingEngine/useCreateMediaSource.tsx b/src/hooks/renderingEngine/useCreateMediaSource.tsx new file mode 100644 index 00000000..c98a0e0d --- /dev/null +++ b/src/hooks/renderingEngine/useCreateMediaSource.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { + AddRenderingEngineSourceResult, + SourceReference +} from '../../interfaces/Source'; +import { Production } from '../../interfaces/production'; +import { CallbackHook } from '../types'; +import { Result } from '../../interfaces/result'; +import { API_SECRET_KEY } from '../../utils/constants'; +import { MediaSource } from '../../interfaces/renderingEngine'; + +export function useCreateMediaSource(): CallbackHook< + ( + production: Production, + inputSlot: number, + mediaBody: MediaSource, + source: SourceReference + ) => Promise> +> { + const [loading, setLoading] = useState(false); + + const createMediaSource = async ( + production: Production, + inputSlot: number, + mediaBody: MediaSource, + source: SourceReference + ): Promise> => { + setLoading(true); + + return fetch(`/api/manager/rendering-engine/media`, { + method: 'POST', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], + body: JSON.stringify({ + production: production, + inputSlot: inputSlot, + mediaBody: mediaBody, + source: source + }) + }) + .then(async (response) => { + if (response.ok) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; + } + throw await response.text(); + }) + .finally(() => setLoading(false)); + }; + return [createMediaSource, loading]; +} diff --git a/src/hooks/renderingEngine/useDeleteHtmlSource.tsx b/src/hooks/renderingEngine/useDeleteHtmlSource.tsx new file mode 100644 index 00000000..fdf29bfc --- /dev/null +++ b/src/hooks/renderingEngine/useDeleteHtmlSource.tsx @@ -0,0 +1,122 @@ +import { MultiviewSettings } from '../../interfaces/multiview'; +import { Production } from '../../interfaces/production'; +import { Result } from '../../interfaces/result'; +import { DeleteRenderingEngineSourceStep } from '../../interfaces/Source'; +import { API_SECRET_KEY } from '../../utils/constants'; +import { CallbackHook } from '../types'; +import { useState } from 'react'; + +export function useDeleteHtmlSource(): CallbackHook< + ( + pipelineUuid: string, + inputSlot: number, + production: Production + ) => Promise> +> { + const [loading, setLoading] = useState(false); + + const deleteHtmlSource = async ( + pipelineUuid: string, + inputSlot: number, + production: Production + ): Promise> => { + setLoading(true); + + const multiviews = production.production_settings.pipelines[0].multiviews; + const multiviewViews = multiviews?.flatMap((singleMultiview) => { + return singleMultiview.layout.views; + }); + const multiviewsToUpdate = multiviewViews?.filter( + (v) => v.input_slot === inputSlot + ); + + if (!multiviewsToUpdate || multiviewsToUpdate.length === 0) { + return fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/html/${inputSlot}`, + { + method: 'DELETE', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]] + } + ) + .then(async (response) => { + if (response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + return data ? data : null; + } + throw await response.text; + }) + .finally(() => setLoading(false)); + } + + const updatedMultiviews = multiviewsToUpdate?.map((view) => { + return { + ...view, + label: view.label + }; + }); + + const rest = multiviewViews?.filter((v) => v.input_slot !== inputSlot); + + const restWithLabels = rest?.map((v) => { + const sourceForView = production.sources.find( + (s) => s.input_slot === v.input_slot + ); + + if (sourceForView) { + return { ...v, label: sourceForView.label }; + } + + return v; + }); + + if ( + !restWithLabels || + !updatedMultiviews || + updatedMultiviews.length === 0 || + !multiviews?.some((singleMultiview) => singleMultiview.layout) + ) { + setLoading(false); + return { + ok: false, + error: 'error' + }; + } + + const multiviewsWithLabels = [...restWithLabels, ...updatedMultiviews]; + + const multiview: MultiviewSettings[] = multiviews.map( + (singleMultiview, index) => ({ + ...singleMultiview, + layout: { + ...singleMultiview.layout, + views: multiviewsWithLabels + }, + for_pipeline_idx: index, + multiviewId: index + 1 + }) + ); + + return fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/html/${inputSlot}`, + { + method: 'DELETE', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], + body: JSON.stringify({ + pipelineId: pipelineUuid, + multiview: multiview + }) + } + ) + .then(async (response) => { + if (response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + return data ? data : null; + } + throw await response.text; + }) + .finally(() => setLoading(false)); + }; + return [deleteHtmlSource, loading]; +} diff --git a/src/hooks/renderingEngine/useDeleteMediaSource.tsx b/src/hooks/renderingEngine/useDeleteMediaSource.tsx new file mode 100644 index 00000000..5b303f1d --- /dev/null +++ b/src/hooks/renderingEngine/useDeleteMediaSource.tsx @@ -0,0 +1,122 @@ +import { MultiviewSettings } from '../../interfaces/multiview'; +import { Production } from '../../interfaces/production'; +import { Result } from '../../interfaces/result'; +import { DeleteRenderingEngineSourceStep } from '../../interfaces/Source'; +import { API_SECRET_KEY } from '../../utils/constants'; +import { CallbackHook } from '../types'; +import { useState } from 'react'; + +export function useDeleteMediaSource(): CallbackHook< + ( + pipelineUuid: string, + inputSlot: number, + production: Production + ) => Promise> +> { + const [loading, setLoading] = useState(false); + + const deleteMediaSource = async ( + pipelineUuid: string, + inputSlot: number, + production: Production + ) => { + setLoading(true); + + const multiviews = production.production_settings.pipelines[0].multiviews; + const multiviewViews = multiviews?.flatMap((singleMultiview) => { + return singleMultiview.layout.views; + }); + const multiviewsToUpdate = multiviewViews?.filter( + (v) => v.input_slot === inputSlot + ); + + if (!multiviewsToUpdate || multiviewsToUpdate.length === 0) { + return fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/media/${inputSlot}`, + { + method: 'DELETE', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]] + } + ) + .then(async (response) => { + if (response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + return data ? data : null; + } + throw await response.text; + }) + .finally(() => setLoading(false)); + } + + const updatedMultiviews = multiviewsToUpdate?.map((view) => { + return { + ...view, + label: view.label + }; + }); + + const rest = multiviewViews?.filter((v) => v.input_slot !== inputSlot); + + const restWithLabels = rest?.map((v) => { + const sourceForView = production.sources.find( + (s) => s.input_slot === v.input_slot + ); + + if (sourceForView) { + return { ...v, label: sourceForView.label }; + } + + return v; + }); + + if ( + !restWithLabels || + !updatedMultiviews || + updatedMultiviews.length === 0 || + !multiviews?.some((singleMultiview) => singleMultiview.layout) + ) { + setLoading(false); + return { + ok: false, + error: 'error' + }; + } + + const multiviewsWithLabels = [...restWithLabels, ...updatedMultiviews]; + + const multiview: MultiviewSettings[] = multiviews.map( + (singleMultiview, index) => ({ + ...singleMultiview, + layout: { + ...singleMultiview.layout, + views: multiviewsWithLabels + }, + for_pipeline_idx: index, + multiviewId: index + 1 + }) + ); + + return fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/media/${inputSlot}`, + { + method: 'DELETE', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], + body: JSON.stringify({ + pipelineId: pipelineUuid, + multiview: multiview + }) + } + ) + .then(async (response) => { + if (response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + return data ? data : null; + } + throw await response.text; + }) + .finally(() => setLoading(false)); + }; + return [deleteMediaSource, loading]; +} diff --git a/src/hooks/renderingEngine/usePipelineHtmlSources.tsx b/src/hooks/renderingEngine/usePipelineHtmlSources.tsx new file mode 100644 index 00000000..8bf33357 --- /dev/null +++ b/src/hooks/renderingEngine/usePipelineHtmlSources.tsx @@ -0,0 +1,28 @@ +import { CallbackHook } from '../types'; +import { useState } from 'react'; +import { API_SECRET_KEY } from '../../utils/constants'; + +export function usePipelineHtmlSources(): CallbackHook< + (pipelineUuid: string) => Promise +> { + const [loading, setLoading] = useState(false); + + const getPipelineHtmlSources = async ( + pipelineUuid: string + ): Promise => { + setLoading(true); + const response = await fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/html/get`, + { + method: 'GET', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]] + } + ); + setLoading(false); + if (response.ok) { + return await response.json(); + } + throw await response.json(); + }; + return [getPipelineHtmlSources, loading]; +} diff --git a/src/hooks/renderingEngine/usePipelineMediaSources.tsx b/src/hooks/renderingEngine/usePipelineMediaSources.tsx new file mode 100644 index 00000000..ee121cc8 --- /dev/null +++ b/src/hooks/renderingEngine/usePipelineMediaSources.tsx @@ -0,0 +1,27 @@ +import { API_SECRET_KEY } from '../../utils/constants'; +import { CallbackHook } from '../types'; +import { useState } from 'react'; + +export function usePipelineMediaSources(): CallbackHook< + (pipelineUuid: string) => Promise +> { + const [loading, setLoading] = useState(false); + const getPipelineMediaSources = async ( + pipelineUuid: string + ): Promise => { + setLoading(true); + const response = await fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine/media/get`, + { + method: 'GET', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]] + } + ); + setLoading(false); + if (response.ok) { + return await response.json(); + } + throw await response.json(); + }; + return [getPipelineMediaSources, loading]; +} diff --git a/src/hooks/renderingEngine/useRenderingEngine.tsx b/src/hooks/renderingEngine/useRenderingEngine.tsx new file mode 100644 index 00000000..6fe2943b --- /dev/null +++ b/src/hooks/renderingEngine/useRenderingEngine.tsx @@ -0,0 +1,29 @@ +import { CallbackHook } from '../types'; +import { useState } from 'react'; +import { API_SECRET_KEY } from '../../utils/constants'; +import { ResourcesRenderingEngineResponse } from '../../../types/ateliere-live'; + +export function useRenderingEngine(): CallbackHook< + (pipelineUuid: string) => Promise +> { + const [loading, setLoading] = useState(false); + + const getRenderingEngine = async ( + pipelineUuid: string + ): Promise => { + setLoading(true); + const response = await fetch( + `/api/manager/pipelines/${pipelineUuid}/rendering-engine`, + { + method: 'GET', + headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]] + } + ); + setLoading(false); + if (response.ok) { + return await response.json(); + } + throw await response.json(); + }; + return [getRenderingEngine, loading]; +} diff --git a/src/hooks/useWebsocket.ts b/src/hooks/useWebsocket.ts deleted file mode 100644 index c6333e03..00000000 --- a/src/hooks/useWebsocket.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { API_SECRET_KEY } from '../utils/constants'; - -export function useWebsocket() { - const closeWebsocket = async ( - action: 'closeMediaplayer' | 'closeHtml', - inputSlot: number - ) => { - return fetch('/api/manager/websocket', { - method: 'POST', - headers: [['x-api-key', `Bearer ${API_SECRET_KEY}`]], - body: JSON.stringify({ action, inputSlot }) - }).then(async (response) => { - if (response.ok) { - return response.json(); - } - throw await response.text(); - }); - }; - return [closeWebsocket]; -} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fcf3ed18..0dacbbd1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -29,15 +29,13 @@ export const en = { sync: 'Synchronize database', start: 'Initialize', monitoring: 'Start runtime monitoring', - unexpected: 'Unexpected error', - websocket: 'Websocket connection' + unexpected: 'Unexpected error' }, stop_production_status: { disconnect_connections: 'Disconnect connections', remove_pipeline_streams: 'Remove streams', remove_pipeline_multiviews: 'Remove multiviews', - unexpected: 'Unexpected error', - websocket: 'Stop websocket' + unexpected: 'Unexpected error' }, source: { type: 'Type: {{type}}', @@ -61,6 +59,34 @@ export const en = { update_multiview: 'Update multiview', unexpected: 'Unexpected error' }, + rendering_engine: { + media: { + create: { + create_media: 'Create media', + filename: 'Filename', + create: 'Create', + filename_error: 'Enter a filename', + abort: 'Cancel' + }, + delete: { + delete_media: 'Delete media', + delete: 'Delete' + } + }, + html: { + create: { + create_html: 'Create HTML', + width: 'HTML graphics width', + height: 'HTML graphics height', + url: 'URL to load', + create: 'Create', + width_error: 'Width must be between 20 and 8192', + height_error: 'Height must be between 20 and 8192', + url_error: 'Enter a URL', + abort: 'Cancel' + } + } + }, empty_slot: { input_slot: 'Input slot' }, diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index 990efe7c..719713f2 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -31,15 +31,13 @@ export const sv = { sync: 'Synkronisera databasen', monitoring: 'Starta monitorering', start: 'Påbörja', - unexpected: 'Oväntat fel', - websocket: 'Websocket anslutning' + unexpected: 'Oväntat fel' }, stop_production_status: { disconnect_connections: 'Frånkoppla anslutningar', remove_pipeline_streams: 'Ta bort strömmar', remove_pipeline_multiviews: 'Ta bort multiviews', - unexpected: 'Oväntat fel', - websocket: 'Stoppa websocket' + unexpected: 'Oväntat fel' }, source: { type: 'Typ: {{type}}', @@ -63,6 +61,34 @@ export const sv = { update_multiview: 'Uppdatera multiview', unexpected: 'Oväntat fel' }, + rendering_engine: { + media: { + create: { + create_media: 'Skapa media', + filename: 'Filnamn', + create: 'Skapa', + filename_error: 'Ange ett filnamn', + abort: 'Avbryt' + }, + delete: { + delete_media: 'Ta bort media', + delete: 'Ta bort' + } + }, + html: { + create: { + create_html: 'Skapa HTML', + width: 'Bredd på grafik', + height: 'Höjd på grafik', + url: 'URL att ladda in', + create: 'Skapa', + width_error: 'Bredden måste vara mellan 20 och 8192', + height_error: 'Höjden måste vara mellan 20 och 8192', + url_error: 'Ange en URL', + abort: 'Avbryt' + } + } + }, empty_slot: { input_slot: 'Ingång' }, diff --git a/src/interfaces/Source.ts b/src/interfaces/Source.ts index 0a3eaf39..5feb83ee 100644 --- a/src/interfaces/Source.ts +++ b/src/interfaces/Source.ts @@ -1,4 +1,5 @@ import { ObjectId, WithId } from 'mongodb'; +import { HTMLSource, MediaSource } from './renderingEngine'; export type SourceType = 'camera' | 'graphics' | 'microphone'; export type SourceStatus = 'ready' | 'new' | 'gone' | 'purge'; export type Type = 'ingest_source' | 'html' | 'mediaplayer'; @@ -40,6 +41,8 @@ export interface SourceReference { label: string; stream_uuids?: string[]; input_slot: number; + html_data?: HTMLSource; + media_data?: MediaSource; } export type SourceWithId = WithId; @@ -56,6 +59,12 @@ export interface DeleteSourceStep { message?: string; } +export interface DeleteRenderingEngineSourceStep { + step: 'delete_html' | 'delete_media' | 'update_multiview'; + success: boolean; + message?: string; +} + export interface DeleteSourceStatus { success: boolean; steps: DeleteSourceStep[]; @@ -83,6 +92,17 @@ export type AddSourceResult = steps: AddSourceStep[]; }; +export type AddRenderingEngineSourceResult = + | { + success: true; + streams: SourceToPipelineStream[]; + steps: AddSourceStep[]; + } + | { + success: false; + steps: AddSourceStep[]; + }; + export interface SrtSource { latency_ms?: number; local_ip?: string; diff --git a/src/interfaces/production.ts b/src/interfaces/production.ts index 074d9307..00c5f480 100644 --- a/src/interfaces/production.ts +++ b/src/interfaces/production.ts @@ -39,7 +39,6 @@ export interface StartProductionStep { step: | 'streams' | 'control_panels' - | 'websocket' | 'pipeline_outputs' | 'multiviews' | 'sync' @@ -60,7 +59,6 @@ export interface StopProductionStep { | 'disconnect_connections' | 'remove_pipeline_streams' | 'remove_pipeline_multiviews' - | 'websocket' | 'unexpected'; success: boolean; message?: string; diff --git a/src/interfaces/renderingEngine.ts b/src/interfaces/renderingEngine.ts new file mode 100644 index 00000000..5402ae63 --- /dev/null +++ b/src/interfaces/renderingEngine.ts @@ -0,0 +1,14 @@ +export interface RenderingEngine { + html: HTMLSource[]; + media: MediaSource[]; +} + +export interface HTMLSource { + height: number; + url?: string; + width: number; +} + +export interface MediaSource { + filename?: string; +} diff --git a/types/ateliere-live.d.ts b/types/ateliere-live.d.ts index e49739ef..9a9c0574 100644 --- a/types/ateliere-live.d.ts +++ b/types/ateliere-live.d.ts @@ -529,6 +529,33 @@ export interface ResourcesFeedbackStream { name: string; } +export interface ResourcesHTMLBrowser { + /** + * Height in pixels of the browser canvas + * @min 20 + * @max 8192 + * @example 720 + */ + height: number; + /** + * The input slot this HTML browser is allocated to + * @example 1 + */ + input_slot: number; + /** + * The active URL in the browser + * @example "https://www.example.com" + */ + url: string; + /** + * Width in pixels of the browser canvas + * @min 20 + * @max 8192 + * @example 1280 + */ + width: number; +} + export interface ResourcesHTTPError { /** * HTTP error code @@ -593,7 +620,7 @@ export interface ResourcesIngestResponse { export interface ResourcesIngestStreamResponse { /** * The average, minimum and maximum time to encode an audio frame in microseconds. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ audio_encode_duration: ResourcesMinMaxAverageTimings; /** @@ -680,12 +707,12 @@ export interface ResourcesIngestStreamResponse { pipeline_uuid: string; /** * The average, minimum and maximum processing time of audio frames in this stream measured from the capture time to handover to the network interface. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ processing_time_audio: ResourcesMinMaxAverageTimings; /** * The average, minimum and maximum processing time of video frames in this stream measured from the capture time to handover to the network interface. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ processing_time_video: ResourcesMinMaxAverageTimings; /** @@ -716,7 +743,7 @@ export interface ResourcesIngestStreamResponse { stream_uuid: string; /** * The average, minimum and maximum time to encode a video frame in microseconds. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ video_encode_duration: ResourcesMinMaxAverageTimings; /** The maximum number of video frames that can be kept in queue before it is full */ @@ -744,6 +771,19 @@ export interface ResourcesListeningInterface { port: number; } +export interface ResourcesMediaPlayer { + /** + * The filename/path of the file to play + * @example "/media/news.mp4" + */ + filename: string; + /** + * The input slot this media player is allocated to + * @example 2 + */ + input_slot: number; +} + export interface ResourcesMinMaxAverageTimings { /** Average time in micro seconds */ avg: number; @@ -904,7 +944,7 @@ export interface ResourcesMultiviewOutputResponse { * @example 1234 */ rendered_frames: number; - /** The average, minimum and maximum time to render a frame on this multi-view output in microseconds, based on the latest 250 frames. */ + /** The average, minimum and maximum time to render a frame on this multi-view output in microseconds, based on the last 250 frames. */ rendering_duration: ResourcesMinMaxAverageTimings; } @@ -1495,6 +1535,8 @@ export interface ResourcesPipelineResponse { * @example "1.2.3.4" */ public_ip: string; + /** The rendering engine specific status */ + rendering_engine: ResourcesRenderingEngineResponse; /** A list of the streams connected to this Pipeline */ streams: ResourcesPipelineStreamResponse[]; /** @@ -1520,7 +1562,7 @@ export interface ResourcesPipelineStreamResponse { alignment_ms: number; /** * The average, minimum and maximum time to decode an audio frame in microseconds. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ audio_decode_duration: ResourcesMinMaxAverageTimings; /** @@ -1612,27 +1654,27 @@ export interface ResourcesPipelineStreamResponse { stream_uuid: string; /** * The average, minimum and maximum "time to arrival" for audio frames in this stream measured from the capture time in the Ingest to handover from the network - * interface in the Pipeline. Based on the latest 250 frames. + * interface in the Pipeline. Based on the last 250 frames. */ time_to_arrival_audio: ResourcesMinMaxAverageTimings; /** * The average, minimum and maximum "time to arrival" for video frames in this stream measured from the capture time in the Ingest to handover from the network - * interface in the Pipeline. Based on the latest 250 frames. + * interface in the Pipeline. Based on the last 250 frames. */ time_to_arrival_video: ResourcesMinMaxAverageTimings; /** * The average, minimum and maximum "time to ready for delivery" of audio frames in this stream measured from the capture time in the Ingest to the time when - * the frames are put in the delivery queue to the Rendering Engine in the Pipeline. Based on the latest 250 frames. + * the frames are put in the delivery queue to the Rendering Engine in the Pipeline. Based on the last 250 frames. */ time_to_ready_audio: ResourcesMinMaxAverageTimings; /** * The average, minimum and maximum "time to ready for delivery" of video frames in this stream measured from the capture time in the Ingest to the time when - * the frames are put in the delivery queue to the Rendering Engine in the Pipeline. Based on the latest 250 frames. + * the frames are put in the delivery queue to the Rendering Engine in the Pipeline. Based on the last 250 frames. */ time_to_ready_video: ResourcesMinMaxAverageTimings; /** * The average, minimum and maximum time to encode a video frame in microseconds. - * Based on the latest 250 frames. + * Based on the last 250 frames. */ video_decode_duration: ResourcesMinMaxAverageTimings; /** The number of video frames currently in queue to the video decoder */ @@ -1664,12 +1706,17 @@ export interface ResourcesReceiverNetworkEndpoint { */ sender_uuid: string; /** - * The transport time of messages from this connection in microseconds based on the latest 20 messages. Measured by comparing the send + * The transport time of messages from this connection in microseconds based on the last 20 messages. Measured by comparing the send * timestamp of the messages with the local time when received. */ transport_duration: ResourcesMinMaxAverageTimings; } +export interface ResourcesRenderingEngineResponse { + html: ResourcesHTMLBrowser[]; + media: ResourcesMediaPlayer[]; +} + export interface ResourcesRoundTripTimeMs { /** * Average round trip time in milliseconds @@ -1779,10 +1826,6 @@ export interface ResourcesSourceResponse { source_id: number; /** Statistics from SRT. Only included for SRT sources. */ srt?: ResourcesSrt; - /** Adjustment of incoming audio timestamps during the latest 250 frames. This field is meant for debugging purposes only and might be removed in future versions. */ - time_adjustment_audio: ResourcesMinMaxAverageTimings; - /** Adjustment of incoming video timestamps during the latest 250 frames. This field is meant for debugging purposes only and might be removed in future versions. */ - time_adjustment_video: ResourcesMinMaxAverageTimings; /** * The type of interface used by the source. NDI for NDI sources, BMD for SDI or HDMI sources using a DeckLink card, SRT for SRT sources * @example "NDI" @@ -1831,11 +1874,6 @@ export interface ResourcesSrt { * @example "AAC" */ audio_format: 'AAC'; - /** - * The number of continuity counter errors found in the input MPEG-TS of this SRT media source - * @example 34 - */ - cc_errors: number; /** SRT statistics */ connection?: ResourcesConnection; /** The number of successfully decoded audio frames */