From 175b0aa4570940530dbea0721b55f546c35ad1b0 Mon Sep 17 00:00:00 2001 From: Axel Boberg Date: Sun, 14 Jan 2024 14:00:08 +0100 Subject: [PATCH] Add thumbnail support to the caspar plugin Signed-off-by: Axel Boberg --- lib/template/template.json | 10 ++- plugins/caspar/app/App.jsx | 3 + .../app/components/ThumbnailImage/index.jsx | 14 ++++ .../app/components/ThumbnailImage/style.css | 17 ++++ plugins/caspar/app/views/Thumbnail.jsx | 55 ++++++++++++ plugins/caspar/index.js | 7 ++ plugins/caspar/lib/AMCP.js | 11 ++- plugins/caspar/lib/Caspar.js | 83 ++++++++++--------- plugins/caspar/lib/handlers.js | 2 +- 9 files changed, 159 insertions(+), 43 deletions(-) create mode 100644 plugins/caspar/app/components/ThumbnailImage/index.jsx create mode 100644 plugins/caspar/app/components/ThumbnailImage/style.css create mode 100644 plugins/caspar/app/views/Thumbnail.jsx diff --git a/lib/template/template.json b/lib/template/template.json index 813589a..94fe251 100644 --- a/lib/template/template.json +++ b/lib/template/template.json @@ -7,13 +7,14 @@ "title": "Rundown", "component": "bridge.internals.grid", "layout": { - "timing": { "x": 0, "y": 0, "w": 6, "h": 2 }, + "liveSwitch": { "x": 0, "y": 0, "w": 6, "h": 2 }, "library": { "x": 0, "y": 2, "w": 6, "h": 10 }, "rundown": { "x": 6, "y": 0, "w": 12, "h": 12 }, - "inspector": { "x": 18, "y": 0, "w": 6, "h": 12 } + "thumbnail": { "x": 18, "y": 0, "w": 6, "h": 3 }, + "inspector": { "x": 18, "y": 3, "w": 6, "h": 9 } }, "children": { - "timing": { + "liveSwitch": { "component": "bridge.plugins.caspar.liveSwitch" }, "library": { @@ -22,6 +23,9 @@ "rundown": { "component": "bridge.plugins.rundown" }, + "thumbnail": { + "component": "bridge.plugins.caspar.thumbnail" + }, "inspector": { "component": "bridge.plugins.inspector" } diff --git a/plugins/caspar/app/App.jsx b/plugins/caspar/app/App.jsx index 9e1f5ba..4a19ac6 100644 --- a/plugins/caspar/app/App.jsx +++ b/plugins/caspar/app/App.jsx @@ -8,6 +8,7 @@ import { InspectorTemplate } from './views/InspectorTemplate' import { InspectorTransition } from './views/InspectorTransition' import { LiveSwitch } from './views/LiveSwitch' +import { Thumbnail } from './views/Thumbnail' import { Library } from './views/Library' import { Status } from './views/Status' @@ -34,6 +35,8 @@ export default function App () { return case 'liveSwitch': return + case 'thumbnail': + return case 'status': return case 'library': diff --git a/plugins/caspar/app/components/ThumbnailImage/index.jsx b/plugins/caspar/app/components/ThumbnailImage/index.jsx new file mode 100644 index 0000000..bbeceb4 --- /dev/null +++ b/plugins/caspar/app/components/ThumbnailImage/index.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import './style.css' + +export const ThumbnailImage = ({ src, alt = 'Thumbnail image' }) => { + return ( +
+ { + src + ? {alt} + :
Thumbnail not available
+ } +
+ ) +} diff --git a/plugins/caspar/app/components/ThumbnailImage/style.css b/plugins/caspar/app/components/ThumbnailImage/style.css new file mode 100644 index 0000000..5c9dbfd --- /dev/null +++ b/plugins/caspar/app/components/ThumbnailImage/style.css @@ -0,0 +1,17 @@ +.ThumbnailImage { + display: flex; + position: relative; + + width: 100%; + height: 100%; + + align-items: center; + justify-content: center; +} + +.ThumbnailImage-img { + width: 100%; + height: 100%; + + object-fit: contain; +} \ No newline at end of file diff --git a/plugins/caspar/app/views/Thumbnail.jsx b/plugins/caspar/app/views/Thumbnail.jsx new file mode 100644 index 0000000..216eae2 --- /dev/null +++ b/plugins/caspar/app/views/Thumbnail.jsx @@ -0,0 +1,55 @@ +import React from 'react' +import bridge from 'bridge' + +import { SharedContext } from '../sharedContext' +import { ThumbnailImage } from '../components/ThumbnailImage' + +export const Thumbnail = () => { + const [state] = React.useContext(SharedContext) + const [item, setItem] = React.useState({}) + const [image, setImage] = React.useState() + const [selection, setSelection] = React.useState([]) + + React.useEffect(() => { + const selection = state?._connections?.[bridge.client.getIdentity()]?.selection + setSelection(selection) + }, [state]) + + React.useEffect(() => { + async function getItem () { + if (!selection || selection.length < 1) { + setImage(undefined) + setItem(undefined) + return + } + const item = await bridge.items.getItem(selection[0]) + + if (item?.type !== 'bridge.caspar.media') { + setImage(undefined) + setItem(undefined) + return + } + + if (!item?.data?.caspar?.server || !item?.data?.caspar?.target) { + setImage(undefined) + setItem(undefined) + return + } + + try { + const res = await bridge.commands.executeCommand('caspar.sendCommand', item?.data?.caspar?.server, 'thumbnailRetrieve', item?.data?.caspar?.target) + const src = (res?.data || []).join('') + setImage(`data:image/png;base64,${src}`) + setItem(item) + } catch (_) { + setImage(undefined) + setItem(undefined) + } + } + getItem() + }, [selection]) + + return ( + + ) +} diff --git a/plugins/caspar/index.js b/plugins/caspar/index.js index 1d2db33..4e3b16f 100644 --- a/plugins/caspar/index.js +++ b/plugins/caspar/index.js @@ -148,4 +148,11 @@ exports.activate = async () => { uri: `${htmlPath}?path=liveSwitch`, description: 'Control the live status of Caspar CG' }) + + bridge.widgets.registerWidget({ + id: 'bridge.plugins.caspar.thumbnail', + name: 'Thumbnail', + uri: `${htmlPath}?path=thumbnail`, + description: 'Display a thumbnail of the selected media item' + }) } diff --git a/plugins/caspar/lib/AMCP.js b/plugins/caspar/lib/AMCP.js index bc49c2e..464f611 100644 --- a/plugins/caspar/lib/AMCP.js +++ b/plugins/caspar/lib/AMCP.js @@ -111,7 +111,7 @@ exports.play = (file, opts) => `PLAY ${layerString(opts)} ${file} ${transitionSt * @param { AMCPOptions } opts * @returns { String } */ -exports.playLoaded = (file, opts) => `PLAY ${layerString(opts)}` +exports.playLoaded = opts => `PLAY ${layerString(opts)}` /** * Stop an item running in the foreground @@ -157,3 +157,12 @@ exports.cgUpdate = (data, opts) => `CG ${layerString(opts)} UPDATE ${opts.cgLaye * @returns { String } */ exports.mixerOpacity = (opacity, opts) => `MIXER ${layerString(opts)} OPACITY ${opacity} ${transitionString(opts)}` + +/** + * Get the thumbnail for a file + * @see https://github.com/CasparCG/help/wiki/AMCP-Protocol#thumbnail-retrieve + * @param { String } fileName + * @param { AMCPOptions } opts + * @returns { String } + */ +exports.thumbnailRetrieve = fileName => `THUMBNAIL RETRIEVE ${fileName}` diff --git a/plugins/caspar/lib/Caspar.js b/plugins/caspar/lib/Caspar.js index 702ce00..89956aa 100644 --- a/plugins/caspar/lib/Caspar.js +++ b/plugins/caspar/lib/Caspar.js @@ -226,59 +226,66 @@ class Caspar extends EventEmitter { /** * @private + * This function processes data received + * as a response from Caspar by bundling chunks + * together and producing a response object, + * which is the resolved through the + * matching transaction + * + * The parsing step is inspired by + * the caspar connector written by + * SuperflyTV + * + * @see https://github.com/SuperFlyTV/casparcg-connection/blob/master/src/connection.ts */ _processData (chunk) { - const newLines = chunk.toString('utf8').split('\r\n') - const lastLine = newLines.pop() - - if (lastLine !== '') { - this._unfinishedLine = lastLine - } else { - this._unfinishedLine = '' + if (!this._unprocessedData) { + this._unprocessedData = '' } - this._unprocessedLines.push(this._unfinishedLine + newLines.shift(), ...newLines) + this._unprocessedData += chunk.toString('utf8') + const newLines = this._unprocessedData.split('\r\n') - if (this._isProcessingData) { - return - } - this._isProcessingData = true + this._unprocessedData = newLines.pop() ?? '' + this._unprocessedLines.push(...newLines) while (this._unprocessedLines.length > 0) { - const line = this._unprocessedLines.shift() - - /* - Finish the response object - if the line is empty and we're - waiting for more lines - */ - if (line === '' && this._currentResponseObject) { - this._resolveResponseObject(this._currentResponseObject) - this._currentResponseObject = undefined - continue - } + const line = this._unprocessedLines[0] + const res = RES_HEADER_REX.exec(line) + + if (res?.groups?.code) { + let processedLines = 1 - /* - Initialize a new response object - if there isn't one already - */ - if (!this._currentResponseObject) { - const header = RES_HEADER_REX.exec(line)?.groups || {} - this._currentResponseObject = { - ...header, + const resObject = { + ...(res?.groups || {}), data: [] } - if (this._unfinishedLine === '') { - this._resolveResponseObject(this._currentResponseObject) - this._currentResponseObject = undefined + if (resObject.code === '200') { + const indexOfTermination = this._unprocessedLines.indexOf('') + + resObject.data = this._unprocessedLines.slice(1, indexOfTermination) + processedLines += resObject.data.length + 1 + } else if (resObject.code === '201' || resObject.code === '400') { + if (this._unprocessedLines.length < 2) { + break + } + resObject.data = [this._unprocessedLines[1]] + processedLines++ } + + this._unprocessedLines.splice(0, processedLines) + + this._resolveResponseObject(resObject) } else { - this._currentResponseObject.data.push(line) + /* + Unknown error, + skip this line + and move on + */ + this._unprocessedLines.splice(0, 1) } } - - this._isProcessingData = false } /** diff --git a/plugins/caspar/lib/handlers.js b/plugins/caspar/lib/handlers.js index 5a93547..fc82320 100644 --- a/plugins/caspar/lib/handlers.js +++ b/plugins/caspar/lib/handlers.js @@ -23,7 +23,7 @@ const PLAY_HANDLERS = { }, 'bridge.caspar.media': async (serverId, item) => { await commands.sendCommand(serverId, 'loadbg', item?.data?.caspar?.target, item?.data?.caspar?.loop, 0, undefined, undefined, undefined, item?.data?.caspar) - return commands.sendCommand(serverId, 'playLoaded', '', item?.data?.caspar) + return commands.sendCommand(serverId, 'playLoaded', item?.data?.caspar) }, 'bridge.caspar.template': (serverId, item) => { return commands.sendCommand(serverId, 'cgAdd', item?.data?.caspar?.target, item?.data?.caspar?.templateData, true, item?.data?.caspar)