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"