Skip to content

Commit 0b380e3

Browse files
committed
feat: ograf-support
1 parent c0784cd commit 0b380e3

11 files changed

Lines changed: 258 additions & 34 deletions

File tree

‎backend/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"electron-is-dev": "^2.0.0",
2828
"eslint": "^9.39.2",
2929
"express": "^5.2.1",
30+
"ograf": "1.1.0",
3031
"socket.io": "^4.8.3",
3132
"type-fest": "^4.41.0",
3233
"uuid": "^11.1.0"

‎backend/src/background/interfaces.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SetOptional } from 'type-fest'
2+
import type * as OGraf from 'ograf'
23

34
export interface Playlist {
45
/** Id of the playlist. */
@@ -133,7 +134,10 @@ export interface DBPiece {
133134
export enum ManifestFieldType {
134135
String = 'string',
135136
Number = 'number',
136-
Boolean = 'boolean'
137+
Boolean = 'boolean',
138+
OGrafForm = 'ograf-form',
139+
Const = 'const',
140+
Enum = 'enum'
137141
}
138142
export enum TypeManifestEntity {
139143
Rundown = 'rundown',
@@ -163,6 +167,14 @@ export interface PayloadManifest {
163167
label: string
164168
type: ManifestFieldType
165169
includeInName?: boolean
170+
defaultValue?: any
171+
/** Only set when type is ograf-form */
172+
ografManifest?: OGraf.GraphicsManifest
173+
/** Only set when type is enum */
174+
enumValues?: {
175+
label: string
176+
value: string
177+
}[]
166178
}
167179

168180
export interface ApplicationSettings {

‎frontend/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"bootstrap": "^5.3.8",
2121
"classnames": "^2.5.1",
2222
"immutability-helper": "^3.1.1",
23+
"ograf": "1.1.0",
24+
"ograf-form": "0.1.3",
2325
"react": "^19.2.3",
2426
"react-bootstrap": "^2.10.10",
2527
"react-datepicker": "^8.10.0",

‎frontend/src/components/rundown/piecePropertiesForm.tsx‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { useForm } from '@tanstack/react-form'
22
import { Button, ButtonGroup, Form, Modal } from 'react-bootstrap'
33
import type { Piece } from '~backend/background/interfaces'
44
import { FieldInfo } from '../form'
5-
import { useState } from 'react'
5+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
6+
import * as OGrafForm from 'ograf-form'
67
import { useNavigate } from '@tanstack/react-router'
78
import { useAppDispatch, useAppSelector } from '~/store/app'
89
import { removePiece, updatePiece } from '~/store/pieces'
@@ -121,7 +122,7 @@ export function PiecePropertiesForm({ piece }: { piece: Piece }) {
121122
children={(field) => (
122123
<>
123124
<Form.Group className="mb-3">
124-
<Form.Label htmlFor={field.name}>{fieldInfo.label}:</Form.Label>
125+
<Form.Label htmlFor={field.name}>{fieldInfo.label}:&nbsp;</Form.Label>
125126

126127
{fieldInfo.type === 'string' && (
127128
<Form.Control
@@ -153,6 +154,36 @@ export function PiecePropertiesForm({ piece }: { piece: Piece }) {
153154
onChange={(e) => field.handleChange(e.target.checked)}
154155
/>
155156
)}
157+
{fieldInfo.type === 'ograf-form' && (
158+
<ReactOGrafForm
159+
schema={fieldInfo.ografManifest?.schema as OGrafForm.GDDSchema}
160+
data={field.state.value}
161+
onDataChangeCallback={(value: any) => {
162+
field.handleChange(value)
163+
}}
164+
/>
165+
)}
166+
167+
{fieldInfo.type === 'enum' && (
168+
<>
169+
<Form.Select
170+
name={field.name}
171+
value={String(field.state.value)}
172+
onBlur={field.handleBlur}
173+
onChange={(e) => {
174+
console.log(e.target.value)
175+
field.handleChange(e.target.value)
176+
}}
177+
>
178+
{fieldInfo.enumValues?.map((ev, i) => (
179+
<option key={i} value={ev.value}>
180+
{ev.label}
181+
</option>
182+
))}
183+
</Form.Select>
184+
</>
185+
)}
186+
{fieldInfo.type === 'const' && <span>{field.state.value}</span>}
156187
</Form.Group>
157188
<FieldInfo field={field} />
158189
</>
@@ -271,3 +302,56 @@ function DeletePieceButton({
271302
</>
272303
)
273304
}
305+
306+
declare global {
307+
namespace React {
308+
namespace JSX {
309+
interface IntrinsicElements {
310+
'superflytv-ograf-form': React.DetailedHTMLProps<
311+
React.HTMLAttributes<HTMLElement> & {
312+
schema: string // OGraf.GraphicsManifest['schema']
313+
value: any
314+
},
315+
HTMLElement
316+
>
317+
}
318+
}
319+
}
320+
}
321+
322+
function ReactOGrafForm(props: {
323+
data: any
324+
schema: OGrafForm.GDDSchema
325+
onDataChangeCallback: (value: any) => void
326+
}) {
327+
const containerRef = useRef<HTMLDivElement>(null)
328+
const ografFormRef = useRef<OGrafForm.SuperFlyTvOgrafDataForm>(null)
329+
330+
useLayoutEffect(() => {
331+
if (ografFormRef.current) return // Already initialized
332+
if (!containerRef.current) {
333+
// Should really not happen
334+
console.error('Div ref not set for OGraf form')
335+
return
336+
}
337+
338+
// Initialize the form OGraf form element:
339+
ografFormRef.current = new OGrafForm.SuperFlyTvOgrafDataForm()
340+
ografFormRef.current.addEventListener('change', () => {
341+
if (ografFormRef.current) props.onDataChangeCallback(ografFormRef.current.value)
342+
})
343+
if (props.schema) ografFormRef.current.schema = props.schema as any
344+
ografFormRef.current.value = props.data ?? {}
345+
346+
containerRef.current.appendChild(ografFormRef.current)
347+
}, [])
348+
349+
useEffect(() => {
350+
if (ografFormRef.current && props.schema) ografFormRef.current.schema = props.schema as any
351+
}, [props.schema])
352+
useEffect(() => {
353+
if (ografFormRef.current) ografFormRef.current.value = props.data
354+
}, [props.data])
355+
356+
return <div ref={containerRef} />
357+
}

‎frontend/src/components/rundown/piecesList.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ function NewPieceButton({
160160
segmentId,
161161
partId,
162162
name: manifest && manifest.includeTypeInName ? manifest.name : 'New piece',
163-
pieceType: selectedPieceType
163+
pieceType: selectedPieceType,
164+
manifest
164165
})
165166
)
166167
.unwrap()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type {
2+
ManifestFieldType,
3+
TypeManifest,
4+
TypeManifestEntity
5+
} from '~backend/background/interfaces'
6+
import type { AppDispatch } from '../../../../store/app'
7+
import { importTypeManifest, updateTypeManifest } from '../../../../store/typeManifest'
8+
import * as OGraf from 'ograf'
9+
import { getDefaultDataFromSchema } from 'ograf-form'
10+
11+
export function isImportOGrafManifest(data: unknown): data is OGraf.GraphicsManifest {
12+
if (typeof data !== 'object' || data === null) return false
13+
if (Array.isArray(data)) return false
14+
15+
const manifest = data as OGraf.GraphicsManifest
16+
17+
return (
18+
manifest.$schema === 'https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json'
19+
)
20+
}
21+
export async function doImportOGrafManifest(
22+
manifest: OGraf.GraphicsManifest,
23+
typeManifests: TypeManifest[],
24+
dispatch: AppDispatch
25+
) {
26+
const id = `ograf-${manifest.id}`
27+
28+
const existing = typeManifests.find((m) => m.id === id)
29+
30+
const typeManifest: TypeManifest = {
31+
id,
32+
colour: '#5555ff',
33+
entityType: 'piece' as TypeManifestEntity,
34+
name: manifest.name,
35+
shortName: manifest.name,
36+
includeTypeInName: true,
37+
38+
payload: [
39+
{
40+
id: 'ograf-id',
41+
label: 'OGraf Id',
42+
type: 'const' as ManifestFieldType,
43+
defaultValue: manifest.id
44+
},
45+
{
46+
id: 'type',
47+
label: 'Type of Graphics',
48+
type: 'enum' as ManifestFieldType,
49+
enumValues: [
50+
{ label: 'Full Screen', value: 'full-screen' },
51+
{ label: 'Overlay 1', value: 'overlay1' },
52+
{ label: 'Overlay 2', value: 'overlay2' },
53+
{ label: 'Overlay 3', value: 'overlay3' }
54+
],
55+
defaultValue: 'overlay1'
56+
},
57+
{
58+
id: 'ograf-data',
59+
label: 'OGraf Data',
60+
type: 'ograf-form' as ManifestFieldType,
61+
ografManifest: manifest,
62+
defaultValue: getDefaultDataFromSchema(manifest.schema)
63+
}
64+
]
65+
}
66+
67+
if (existing) {
68+
await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest }))
69+
} else {
70+
await dispatch(importTypeManifest({ typeManifest }))
71+
}
72+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { TypeManifest } from '~backend/background/interfaces'
2+
import type { AppDispatch } from '../../../../store/app'
3+
import { importTypeManifest, updateTypeManifest } from '../../../../store/typeManifest'
4+
5+
export function isImportTypeManifest(arr: unknown): arr is TypeManifest[] {
6+
return (
7+
Array.isArray(arr) &&
8+
arr.every((t) => 'id' in t && 'entityType' in t && 'name' in t && 'payload' in t)
9+
)
10+
}
11+
export async function doImportTypeManifest(
12+
importJson: TypeManifest[],
13+
typeManifests: TypeManifest[],
14+
dispatch: AppDispatch
15+
) {
16+
await Promise.all(
17+
importJson.map(async (t) => {
18+
const existing = typeManifests.find((m) => m.id === t.id)
19+
try {
20+
if (existing) {
21+
await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest: t }))
22+
} else {
23+
await dispatch(importTypeManifest({ typeManifest: t }))
24+
}
25+
} catch (e) {
26+
console.error(e)
27+
throw e
28+
}
29+
})
30+
)
31+
}

‎frontend/src/components/settings/typeManifestForm/typeManifestForm.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ export function TypeManifestForm({
229229
<option value={ManifestFieldType.String}>String</option>
230230
<option value={ManifestFieldType.Number}>Number</option>
231231
<option value={ManifestFieldType.Boolean}>Boolean</option>
232+
<option value={ManifestFieldType.Enum}>Enum</option>
233+
<option value={ManifestFieldType.Const}>Constant</option>
234+
<option value={ManifestFieldType.OGrafForm}>OGraf data</option>
232235
</Form.Select>
233236
<FieldInfo field={field} />
234237
</>

‎frontend/src/components/settings/typeManifestForm/typeManifestsForm.tsx‎

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { Accordion, Button, ButtonGroup } from 'react-bootstrap'
22
import { useAppDispatch } from '~/store/app'
3-
import { addNewTypeManifest, importTypeManifest, updateTypeManifest } from '~/store/typeManifest'
3+
import { addNewTypeManifest } from '~/store/typeManifest'
44
import { ipcAPI } from '~/lib/IPC'
55
import { TypeManifestEntity } from '~backend/background/interfaces'
66
import type { TypeManifest } from '~backend/background/interfaces'
77
import './typesForm.scss'
88
import { useToasts } from '~/components/toasts/useToasts'
99
import { TypeManifestForm } from './typeManifestForm'
10+
import { doImportTypeManifest, isImportTypeManifest } from './lib/importType'
11+
import { doImportOGrafManifest, isImportOGrafManifest } from './lib/importOGrafManifest'
1012

1113
export function TypeManifestsForm({
1214
typeManifests,
@@ -36,35 +38,25 @@ export function TypeManifestsForm({
3638
// Import types
3739
const importTypes = () => {
3840
ipcAPI.openFromFile({ title: `Import ${title}` }).then(async (imported) => {
39-
const verify = (arr: unknown): arr is TypeManifest[] =>
40-
Array.isArray(arr) &&
41-
arr.every((t) => 'id' in t && 'entityType' in t && 'name' in t && 'payload' in t)
41+
// Is OGraf manifest file?
42+
// console.log('imported', imported)
4243

43-
if (!verify(imported)) {
44-
toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Invalid file' })
45-
return
46-
}
47-
48-
await Promise.all(
49-
imported.map(async (t) => {
50-
const existing = typeManifests.find((m) => m.id === t.id)
51-
try {
52-
if (existing) {
53-
await dispatch(updateTypeManifest({ originalId: existing.id, typeManifest: t }))
54-
} else {
55-
await dispatch(importTypeManifest({ typeManifest: t }))
56-
}
57-
} catch (e) {
58-
console.error(e)
59-
toasts.show({
60-
headerContent: `Import ${title}`,
61-
bodyContent: 'Unexpected error'
62-
})
63-
}
44+
try {
45+
if (isImportOGrafManifest(imported)) {
46+
await doImportOGrafManifest(imported, typeManifests, dispatch)
47+
toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' })
48+
} else if (isImportTypeManifest(imported)) {
49+
await doImportTypeManifest(imported, typeManifests, dispatch)
50+
toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' })
51+
} else {
52+
toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Invalid file' })
53+
}
54+
} catch (e) {
55+
toasts.show({
56+
headerContent: `Import ${title}`,
57+
bodyContent: 'Unexpected error, se console for details'
6458
})
65-
)
66-
67-
toasts.show({ headerContent: `Import ${title}`, bodyContent: 'Import complete' })
59+
}
6860
})
6961
}
7062

‎frontend/src/store/pieces.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type {
22
MutationPieceCloneFromParToPart,
33
MutationPieceCopy,
4-
Piece
4+
Piece,
5+
TypeManifest
56
} from '~backend/background/interfaces.js'
67
import { createSlice } from '@reduxjs/toolkit'
78
import { createAppAsyncThunk } from './app'
@@ -19,6 +20,7 @@ export interface NewPiecePayload {
1920

2021
name: string
2122
pieceType: string
23+
manifest?: TypeManifest
2224
}
2325
export interface UpdatePiecePayload {
2426
piece: Piece
@@ -30,14 +32,21 @@ export interface RemovePiecePayload {
3032
export const addNewPiece = createAppAsyncThunk(
3133
'pieces/addNewPiece',
3234
async (payload: NewPiecePayload) => {
35+
const piecePayload: Record<string, any> = {}
36+
if (payload.manifest) {
37+
for (const payloadManifest of payload.manifest.payload) {
38+
if (payloadManifest.defaultValue)
39+
piecePayload[payloadManifest.id] = payloadManifest.defaultValue
40+
}
41+
}
3342
return ipcAPI.addNewPiece({
3443
name: payload.name,
3544
playlistId: payload.playlistId,
3645
rundownId: payload.rundownId,
3746
segmentId: payload.segmentId,
3847
partId: payload.partId,
3948
pieceType: payload.pieceType,
40-
payload: {}
49+
payload: piecePayload
4150
})
4251
}
4352
)

0 commit comments

Comments
 (0)