diff --git a/backend/package.json b/backend/package.json index 15a71ee..cb9a982 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "electron-is-dev": "^2.0.0", "eslint": "^9.39.2", "express": "^5.2.1", + "ograf": "1.1.0", "socket.io": "^4.8.3", "type-fest": "^4.41.0", "uuid": "^11.1.0" diff --git a/backend/src/background/interfaces.ts b/backend/src/background/interfaces.ts index 43da993..5896813 100644 --- a/backend/src/background/interfaces.ts +++ b/backend/src/background/interfaces.ts @@ -1,4 +1,5 @@ import type { SetOptional } from 'type-fest' +import type * as OGraf from 'ograf' export interface Playlist { /** Id of the playlist. */ @@ -133,7 +134,10 @@ export interface DBPiece { export enum ManifestFieldType { String = 'string', Number = 'number', - Boolean = 'boolean' + Boolean = 'boolean', + OGrafForm = 'ograf-form', + Const = 'const', + Enum = 'enum' } export enum TypeManifestEntity { Rundown = 'rundown', @@ -163,6 +167,14 @@ export interface PayloadManifest { label: string type: ManifestFieldType includeInName?: boolean + defaultValue?: any + /** Only set when type is ograf-form */ + ografManifest?: OGraf.GraphicsManifest + /** Only set when type is enum */ + enumValues?: { + label: string + value: string + }[] } export interface ApplicationSettings { diff --git a/frontend/package.json b/frontend/package.json index 35fb2c7..3c6c068 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "bootstrap": "^5.3.8", "classnames": "^2.5.1", "immutability-helper": "^3.1.1", + "ograf": "1.1.0", + "ograf-form": "0.1.3", "react": "^19.2.3", "react-bootstrap": "^2.10.10", "react-datepicker": "^8.10.0", diff --git a/frontend/src/components/rundown/piecePropertiesForm.tsx b/frontend/src/components/rundown/piecePropertiesForm.tsx index 5e4dc8d..b674145 100644 --- a/frontend/src/components/rundown/piecePropertiesForm.tsx +++ b/frontend/src/components/rundown/piecePropertiesForm.tsx @@ -2,7 +2,8 @@ import { useForm } from '@tanstack/react-form' import { Button, ButtonGroup, Form, Modal } from 'react-bootstrap' import type { Piece } from '~backend/background/interfaces' import { FieldInfo } from '../form' -import { useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import * as OGrafForm from 'ograf-form' import { useNavigate } from '@tanstack/react-router' import { useAppDispatch, useAppSelector } from '~/store/app' import { removePiece, updatePiece } from '~/store/pieces' @@ -121,7 +122,7 @@ export function PiecePropertiesForm({ piece }: { piece: Piece }) { children={(field) => ( <> - {fieldInfo.label}: + {fieldInfo.label}:  {fieldInfo.type === 'string' && ( field.handleChange(e.target.checked)} /> )} + {fieldInfo.type === 'ograf-form' && ( + { + field.handleChange(value) + }} + /> + )} + + {fieldInfo.type === 'enum' && ( + <> + { + console.log(e.target.value) + field.handleChange(e.target.value) + }} + > + {fieldInfo.enumValues?.map((ev, i) => ( + + ))} + + + )} + {fieldInfo.type === 'const' && {field.state.value}} @@ -271,3 +302,56 @@ function DeletePieceButton({ ) } + +declare global { + namespace React { + namespace JSX { + interface IntrinsicElements { + 'superflytv-ograf-form': React.DetailedHTMLProps< + React.HTMLAttributes & { + schema: string // OGraf.GraphicsManifest['schema'] + value: any + }, + HTMLElement + > + } + } + } +} + +function ReactOGrafForm(props: { + data: any + schema: OGrafForm.GDDSchema + onDataChangeCallback: (value: any) => void +}) { + const containerRef = useRef(null) + const ografFormRef = useRef(null) + + useLayoutEffect(() => { + if (ografFormRef.current) return // Already initialized + if (!containerRef.current) { + // Should really not happen + console.error('Div ref not set for OGraf form') + return + } + + // Initialize the form OGraf form element: + ografFormRef.current = new OGrafForm.SuperFlyTvOgrafDataForm() + ografFormRef.current.addEventListener('change', () => { + if (ografFormRef.current) props.onDataChangeCallback(ografFormRef.current.value) + }) + if (props.schema) ografFormRef.current.schema = props.schema as any + ografFormRef.current.value = props.data ?? {} + + containerRef.current.appendChild(ografFormRef.current) + }, []) + + useEffect(() => { + if (ografFormRef.current && props.schema) ografFormRef.current.schema = props.schema as any + }, [props.schema]) + useEffect(() => { + if (ografFormRef.current) ografFormRef.current.value = props.data + }, [props.data]) + + return
+} diff --git a/frontend/src/components/rundown/piecesList.tsx b/frontend/src/components/rundown/piecesList.tsx index 62649dc..c2d56d7 100644 --- a/frontend/src/components/rundown/piecesList.tsx +++ b/frontend/src/components/rundown/piecesList.tsx @@ -160,7 +160,8 @@ function NewPieceButton({ segmentId, partId, name: manifest && manifest.includeTypeInName ? manifest.name : 'New piece', - pieceType: selectedPieceType + pieceType: selectedPieceType, + manifest }) ) .unwrap() diff --git a/frontend/src/components/settings/typeManifestForm/lib/importOGrafManifest.ts b/frontend/src/components/settings/typeManifestForm/lib/importOGrafManifest.ts new file mode 100644 index 0000000..0a07c14 --- /dev/null +++ b/frontend/src/components/settings/typeManifestForm/lib/importOGrafManifest.ts @@ -0,0 +1,72 @@ +import type { + ManifestFieldType, + TypeManifest, + TypeManifestEntity +} from '~backend/background/interfaces' +import type { AppDispatch } from '../../../../store/app' +import { importTypeManifest, updateTypeManifest } from '../../../../store/typeManifest' +import * as OGraf from 'ograf' +import { getDefaultDataFromSchema } from 'ograf-form' + +export function isImportOGrafManifest(data: unknown): data is OGraf.GraphicsManifest { + if (typeof data !== 'object' || data === null) return false + if (Array.isArray(data)) return false + + const manifest = data as OGraf.GraphicsManifest + + return ( + manifest.$schema === 'https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json' + ) +} +export async function doImportOGrafManifest( + manifest: OGraf.GraphicsManifest, + typeManifests: TypeManifest[], + dispatch: AppDispatch +) { + const id = `ograf-${manifest.id}` + + const existing = typeManifests.find((m) => m.id === id) + + const typeManifest: TypeManifest = { + id, + colour: '#5555ff', + entityType: 'piece' as TypeManifestEntity, + name: manifest.name, + shortName: manifest.name, + includeTypeInName: true, + + payload: [ + { + id: 'ograf-id', + label: 'OGraf Id', + type: 'const' as ManifestFieldType, + defaultValue: manifest.id + }, + { + id: 'type', + label: 'Type of Graphics', + type: 'enum' as ManifestFieldType, + enumValues: [ + { label: 'Full Screen', value: 'full-screen' }, + { label: 'Overlay 1', value: 'overlay1' }, + { label: 'Overlay 2', value: 'overlay2' }, + { label: 'Overlay 3', value: 'overlay3' } + ], + defaultValue: 'overlay1' + }, + { + id: 'ograf-data', + label: 'OGraf Data', + type: 'ograf-form' as ManifestFieldType, + ografManifest: manifest, + defaultValue: getDefaultDataFromSchema(manifest.schema) + } + ] + } + + if (existing) { + await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest })) + } else { + await dispatch(importTypeManifest({ typeManifest })) + } +} diff --git a/frontend/src/components/settings/typeManifestForm/lib/importType.ts b/frontend/src/components/settings/typeManifestForm/lib/importType.ts new file mode 100644 index 0000000..ad5344c --- /dev/null +++ b/frontend/src/components/settings/typeManifestForm/lib/importType.ts @@ -0,0 +1,31 @@ +import type { TypeManifest } from '~backend/background/interfaces' +import type { AppDispatch } from '../../../../store/app' +import { importTypeManifest, updateTypeManifest } from '../../../../store/typeManifest' + +export function isImportTypeManifest(arr: unknown): arr is TypeManifest[] { + return ( + Array.isArray(arr) && + arr.every((t) => 'id' in t && 'entityType' in t && 'name' in t && 'payload' in t) + ) +} +export async function doImportTypeManifest( + importJson: TypeManifest[], + typeManifests: TypeManifest[], + dispatch: AppDispatch +) { + await Promise.all( + importJson.map(async (t) => { + const existing = typeManifests.find((m) => m.id === t.id) + try { + if (existing) { + await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest: t })) + } else { + await dispatch(importTypeManifest({ typeManifest: t })) + } + } catch (e) { + console.error(e) + throw e + } + }) + ) +} diff --git a/frontend/src/components/settings/typeManifestForm/typeManifestForm.tsx b/frontend/src/components/settings/typeManifestForm/typeManifestForm.tsx index cd55a77..ae1f8a8 100644 --- a/frontend/src/components/settings/typeManifestForm/typeManifestForm.tsx +++ b/frontend/src/components/settings/typeManifestForm/typeManifestForm.tsx @@ -229,6 +229,9 @@ export function TypeManifestForm({ + + + diff --git a/frontend/src/components/settings/typeManifestForm/typeManifestsForm.tsx b/frontend/src/components/settings/typeManifestForm/typeManifestsForm.tsx index e1f4805..3bb359c 100644 --- a/frontend/src/components/settings/typeManifestForm/typeManifestsForm.tsx +++ b/frontend/src/components/settings/typeManifestForm/typeManifestsForm.tsx @@ -1,12 +1,14 @@ import { Accordion, Button, ButtonGroup } from 'react-bootstrap' import { useAppDispatch } from '~/store/app' -import { addNewTypeManifest, importTypeManifest, updateTypeManifest } from '~/store/typeManifest' +import { addNewTypeManifest } from '~/store/typeManifest' import { ipcAPI } from '~/lib/IPC' import { TypeManifestEntity } from '~backend/background/interfaces' import type { TypeManifest } from '~backend/background/interfaces' import './typesForm.scss' import { useToasts } from '~/components/toasts/useToasts' import { TypeManifestForm } from './typeManifestForm' +import { doImportTypeManifest, isImportTypeManifest } from './lib/importType' +import { doImportOGrafManifest, isImportOGrafManifest } from './lib/importOGrafManifest' export function TypeManifestsForm({ typeManifests, @@ -36,35 +38,25 @@ export function TypeManifestsForm({ // Import types const importTypes = () => { ipcAPI.openFromFile({ title: `Import ${title}` }).then(async (imported) => { - const verify = (arr: unknown): arr is TypeManifest[] => - Array.isArray(arr) && - arr.every((t) => 'id' in t && 'entityType' in t && 'name' in t && 'payload' in t) + // Is OGraf manifest file? + // console.log('imported', imported) - if (!verify(imported)) { - toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Invalid file' }) - return - } - - await Promise.all( - imported.map(async (t) => { - const existing = typeManifests.find((m) => m.id === t.id) - try { - if (existing) { - await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest: t })) - } else { - await dispatch(importTypeManifest({ typeManifest: t })) - } - } catch (e) { - console.error(e) - toasts.show({ - headerContent: `Import ${title}`, - bodyContent: 'Unexpected error' - }) - } + try { + if (isImportOGrafManifest(imported)) { + await doImportOGrafManifest(imported, typeManifests, dispatch) + toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' }) + } else if (isImportTypeManifest(imported)) { + await doImportTypeManifest(imported, typeManifests, dispatch) + toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' }) + } else { + toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Invalid file' }) + } + } catch (e) { + toasts.show({ + headerContent: `Import ${title}`, + bodyContent: 'Unexpected error, se console for details' }) - ) - - toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' }) + } }) } diff --git a/frontend/src/store/pieces.ts b/frontend/src/store/pieces.ts index 114def5..2f865e3 100644 --- a/frontend/src/store/pieces.ts +++ b/frontend/src/store/pieces.ts @@ -1,7 +1,8 @@ import type { MutationPieceCloneFromParToPart, MutationPieceCopy, - Piece + Piece, + TypeManifest } from '~backend/background/interfaces.js' import { createSlice } from '@reduxjs/toolkit' import { createAppAsyncThunk } from './app' @@ -19,6 +20,7 @@ export interface NewPiecePayload { name: string pieceType: string + manifest?: TypeManifest } export interface UpdatePiecePayload { piece: Piece @@ -30,6 +32,13 @@ export interface RemovePiecePayload { export const addNewPiece = createAppAsyncThunk( 'pieces/addNewPiece', async (payload: NewPiecePayload) => { + const piecePayload: Record = {} + if (payload.manifest) { + for (const payloadManifest of payload.manifest.payload) { + if (payloadManifest.defaultValue) + piecePayload[payloadManifest.id] = payloadManifest.defaultValue + } + } return ipcAPI.addNewPiece({ name: payload.name, playlistId: payload.playlistId, @@ -37,7 +46,7 @@ export const addNewPiece = createAppAsyncThunk( segmentId: payload.segmentId, partId: payload.partId, pieceType: payload.pieceType, - payload: {} + payload: piecePayload }) } ) diff --git a/yarn.lock b/yarn.lock index dcdf5bc..2d6042a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1412,6 +1412,7 @@ __metadata: eslint: "npm:^9.39.2" express: "npm:^5.2.1" nodemon: "npm:^3.1.11" + ograf: "npm:1.1.0" socket.io: "npm:^4.8.3" type-fest: "npm:^4.41.0" typescript: "npm:~5.8.3" @@ -1439,6 +1440,8 @@ __metadata: classnames: "npm:^2.5.1" globals: "npm:^16.5.0" immutability-helper: "npm:^3.1.1" + ograf: "npm:1.1.0" + ograf-form: "npm:0.1.3" react: "npm:^19.2.3" react-bootstrap: "npm:^2.10.10" react-datepicker: "npm:^8.10.0" @@ -4710,6 +4713,20 @@ __metadata: languageName: node linkType: hard +"ograf-form@npm:0.1.3": + version: 0.1.3 + resolution: "ograf-form@npm:0.1.3" + checksum: 10c0/0132547ebb79240bdc8841b969c3efa9bb47498882d4845c901527180fa70dc073f573134448425295a35f0154ecc4d28c7b6450fb10511889f4b426626b221d + languageName: node + linkType: hard + +"ograf@npm:1.1.0": + version: 1.1.0 + resolution: "ograf@npm:1.1.0" + checksum: 10c0/048bfd3f6c4ae97fff3d749db50dc628340caba3447f4d5a387b67f1756ec83ed13e2f84fb0bab3dc8d7ff8d5bee9c69918549816ab123917838961e77c4a45e + languageName: node + linkType: hard + "on-finished@npm:^2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1"