diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..1d7ac85
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..ee214f9
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ }
+}
diff --git a/README.md b/README.md
index 7f82739..3eb60d7 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,13 @@ Bumper content are clips which can be used inbetween the main content of your TV
A file is output for each channel on your service, and which is replaced each time a new show starts (with an updated bumper based on what will be playing next). This content can then be imported in your chosen IPTV service.
+---
+
+
🛠️ Project under active development 🛠️
+There is no official release yet (though dev releases are available on ollyscoding/bumpgen-test:<commit-sha>), and breaking changes will be actively made until v0.1 is released
+
+---
+
## Getting Started
You can run bumpgen through docker compose, create a docker compose file from the [the template](./docs/compose.yml).
@@ -13,3 +20,36 @@ You'll need to update the `/path/to/your/output` and `/path/to/your/background-c
If you haven't already, create the config directory, then create a file called `bumpgen.config.json` within it, and copy across the contents of [bumpgen.config.example.json](./configs/bumpgen.config.example.json). Then add the URL for your XML TV file, and change any other settings as desired.
You should now be good to go, run `docker compose up -d` to get started!
+
+### Templates
+
+Bumpgen offers a number of default templates to use for your channels, as well as exerimental plugin support if you want to develop your own templates.
+
+
+
+ | Name |
+ Example |
+
+
+ centre-title-and-time |
+  |
+
+
+ left-panel-info |
+  |
+
+
+ left-panel-next-five |
+  |
+
+
+
+
+## What's still needed for MVP?
+
+- Give templates all upcoming shows not just the next one & build 'left-panel-next-5' template
+- Options for resolution (per channel)
+- Option for length including \* as 'fill'
+- Plugins: some kind of versioning + publish types + example repo + language/locale in template
+- Frontend for configuring
+- Allow animations?
diff --git a/apps/backend/src/canvas2video/types.ts b/apps/backend/src/canvas2video/types.ts
index 55760f9..cffe189 100644
--- a/apps/backend/src/canvas2video/types.ts
+++ b/apps/backend/src/canvas2video/types.ts
@@ -32,7 +32,7 @@ type makeSceneFunction = (
canvas: fabric.StaticCanvas,
anim: gsap.core.Timeline,
compose: () => void,
-) => void;
+) => Promise;
export interface RendererConfig {
width: number;
diff --git a/apps/backend/src/config/app.ts b/apps/backend/src/config/app.ts
index 530c79a..7aa1ba1 100644
--- a/apps/backend/src/config/app.ts
+++ b/apps/backend/src/config/app.ts
@@ -12,7 +12,7 @@ addFormats(ajv);
type ChannelId = string;
export interface ChannelConfig {
- template: "centre-title-and-time";
+ template: string;
backgroundContent: "*" | string[];
}
@@ -54,7 +54,7 @@ const schema: JSONSchemaType = {
properties: {
template: {
type: "string",
- enum: ["centre-title-and-time"],
+ // enum: ["centre-title-and-time"],
},
backgroundContent: {
oneOf: [
diff --git a/apps/backend/src/jobs/main.ts b/apps/backend/src/jobs/main.ts
index 5dc54ce..9dda487 100644
--- a/apps/backend/src/jobs/main.ts
+++ b/apps/backend/src/jobs/main.ts
@@ -1,6 +1,6 @@
import {
fetchAndParseXmlTv,
- getNextProgrammeForChannel,
+ getNextProgrammesForChannel,
getValueForConfiguredLang,
} from "../xmltv/index.js";
import {
@@ -12,7 +12,7 @@ import {
type Result,
} from "../result/index.js";
import {
- createOverlayConfigFromProgramme,
+ createProgrammeInfoFromProgrammes,
makeVideo,
} from "../video-generator/index.js";
import { appConfig, type ChannelConfig } from "../config/app.js";
@@ -67,14 +67,17 @@ const channelTask = async (
programmes: XmltvProgramme[],
channelConfig: ChannelConfig,
): ReturnType => {
- const currentProgramme = getNextProgrammeForChannel(channel, programmes);
- if (isFailure(currentProgramme)) {
- return failure("Failed to get next programme", currentProgramme.error);
+ const nextProgrammes = getNextProgrammesForChannel(channel, programmes);
+ if (isFailure(nextProgrammes)) {
+ return failure("Failed to get next programme", nextProgrammes.error);
}
- const overlay = createOverlayConfigFromProgramme(currentProgramme.result);
- if (isFailure(overlay)) {
- return failure("Failed to get overlay config", overlay.error);
+ const programmeInfo = createProgrammeInfoFromProgrammes(
+ nextProgrammes.result,
+ );
+
+ if (isFailure(programmeInfo)) {
+ return failure("Failed to get overlay config", programmeInfo.error);
}
const backgroundContentPath =
@@ -90,7 +93,7 @@ const channelTask = async (
return makeVideo({
template: template.result,
- overlay: overlay.result,
+ programmes: programmeInfo.result,
outputDir: appConfig.outputFolder,
outputFileName: `channel-${channel.id}.mp4`,
length: 20,
diff --git a/apps/backend/src/video-generator/index.ts b/apps/backend/src/video-generator/index.ts
index b0b6fed..c44dd6b 100644
--- a/apps/backend/src/video-generator/index.ts
+++ b/apps/backend/src/video-generator/index.ts
@@ -1,5 +1,7 @@
import type { XmltvProgramme } from "@iptv/xmltv";
-import type { FabricTemplate } from "bumpgen-shared/types";
+import type { FabricTemplate, ProgrammeInfo } from "bumpgen-shared/types";
+import { isNotUndefined } from "bumpgen-shared/utils";
+
import {
failure,
isFailure,
@@ -22,15 +24,6 @@ import { logDebug, logError } from "../logger/index.js";
import { existsSync } from "node:fs";
import { Fonts } from "../fonts/index.js";
-export interface VideoOverlay {
- title: string;
- subtitle?: string;
- episode?: string;
- description?: string;
- iconUrl?: string;
- start?: Date;
-}
-
export interface ChannelInfo {
id: string;
name?: string;
@@ -44,7 +37,7 @@ export interface VideoBackground {
export interface VideoOptions {
channelInfo: ChannelInfo;
- overlay: VideoOverlay;
+ programmes: [ProgrammeInfo, ...ProgrammeInfo[]];
background?: VideoBackground;
outputDir: string;
outputFileName: string;
@@ -72,7 +65,7 @@ const getBackgroundVideoStartAndEnd = (
};
const getProgrammeId = (options: VideoOptions) =>
- `${options.overlay.title}-${options.overlay.episode}`;
+ `${options.programmes[0].title}-${options.programmes[0].episode}`;
const shouldGenerateVideo = async (
options: VideoOptions,
@@ -134,8 +127,8 @@ export const makeVideo = async (
width,
height,
fps: 1,
- makeScene: (fabric, canvas, anim, compose) => {
- options.template(options.overlay, {
+ makeScene: async (fabric, canvas, anim, compose) => {
+ await options.template(options.programmes, {
getFontProperties: (...args) => Fonts.getFontProperties(...args),
convertX: (val: number) => val * width,
convertY: (val: number) => val * height,
@@ -183,25 +176,46 @@ export const makeVideo = async (
}
};
-export const createOverlayConfigFromProgramme = (
- programme: XmltvProgramme,
-): Result => {
- const title = getValueForConfiguredLang(programme.title);
- if (isFailure(title)) {
- return failure("Title required to create overlay");
+export const createProgrammeInfoFromProgrammes = (
+ programmes: [XmltvProgramme, ...XmltvProgramme[]],
+): Result<[ProgrammeInfo, ...ProgrammeInfo[]]> => {
+ const createProgrammeInfo = (
+ programme: XmltvProgramme,
+ ): Result => {
+ const title = getValueForConfiguredLang(programme.title);
+ if (isFailure(title)) {
+ return failure("Title required to create overlay");
+ }
+
+ const subtitle = getValueForConfiguredLang(programme.subTitle);
+ const episode = getOnScreenEpisodeNumber(programme.episodeNum);
+ const description = getValueForConfiguredLang(programme.desc);
+ const iconUrl = getBestIcon(programme.icon);
+
+ return success({
+ title: title.result,
+ subtitle: unwrap(subtitle),
+ episode: unwrap(episode),
+ description: unwrap(description),
+ start: programme.start,
+ end: programme.stop,
+ iconUrl: unwrap(iconUrl),
+ });
+ };
+
+ const firstItemResult = createProgrammeInfo(programmes[0]);
+
+ if (isFailure(firstItemResult)) {
+ // Pass on the failure
+ return firstItemResult;
}
- const subtitle = getValueForConfiguredLang(programme.subTitle);
- const episode = getOnScreenEpisodeNumber(programme.episodeNum);
- const description = getValueForConfiguredLang(programme.desc);
- const iconUrl = getBestIcon(programme.icon);
-
- return success({
- title: title.result,
- subtitle: unwrap(subtitle),
- episode: unwrap(episode),
- description: unwrap(description),
- start: programme.start,
- iconUrl: unwrap(iconUrl),
- });
+ return success([
+ firstItemResult.result,
+ ...programmes
+ .slice(1)
+ .map(createProgrammeInfo)
+ .map(unwrap)
+ .filter(isNotUndefined),
+ ]);
};
diff --git a/apps/backend/src/xmltv/index.ts b/apps/backend/src/xmltv/index.ts
index a6be9b4..56b6e04 100644
--- a/apps/backend/src/xmltv/index.ts
+++ b/apps/backend/src/xmltv/index.ts
@@ -21,7 +21,9 @@ export const fetchAndParseXmlTv = async (
try {
const response = await fetch(path);
const body = await response.text();
- const xmlTv = parseXmltv(body);
+ // Fix a weird bug where the apostrophe specifically isn't decoded
+ const bodyFixed = body.replaceAll("'", "'");
+ const xmlTv = parseXmltv(bodyFixed);
const { channels, programmes } = xmlTv;
if (!channels) {
@@ -40,29 +42,32 @@ export const fetchAndParseXmlTv = async (
}
};
-export const getNextProgrammeForChannel = (
+export const getNextProgrammesForChannel = (
channel: XmltvChannel,
programmes: XmltvProgramme[],
-): Result => {
+): Result<[XmltvProgramme, ...XmltvProgramme[]]> => {
const forChannel = programmes.filter((p) => p.channel === channel.id);
const sorted = [...forChannel].sort((a, b) => {
return a.start.getTime() - b.start.getTime();
});
- const current = sorted.find((programme) => {
+ const nextUpIndex = sorted.findIndex((programme) => {
if (Date.now() < programme.start.getTime()) return true;
else return false;
});
- if (current) {
- return success(current);
- } else {
+ if (nextUpIndex === -1) {
logDebug("Failed to find current program for channel " + channel.id);
return failure(
"Failed to find current programme playing on channel with id " +
channel.id,
);
}
+
+ return success([
+ programmes[nextUpIndex]!,
+ ...programmes.slice(nextUpIndex + 1),
+ ]);
};
export const getOnScreenEpisodeNumber = (
diff --git a/docs/screenshots/template_centre-title-and-time.png b/docs/screenshots/template_centre-title-and-time.png
new file mode 100644
index 0000000..58c3fda
Binary files /dev/null and b/docs/screenshots/template_centre-title-and-time.png differ
diff --git a/docs/screenshots/template_left-panel-info.png b/docs/screenshots/template_left-panel-info.png
new file mode 100644
index 0000000..8a14785
Binary files /dev/null and b/docs/screenshots/template_left-panel-info.png differ
diff --git a/docs/screenshots/template_left-panel-next-five.png b/docs/screenshots/template_left-panel-next-five.png
new file mode 100644
index 0000000..a52adbb
Binary files /dev/null and b/docs/screenshots/template_left-panel-next-five.png differ
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a70abad..a96d566 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -7,5 +7,10 @@ export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.strict,
tseslint.configs.stylistic,
+ {
+ rules: {
+ "@typescript-eslint/no-non-null-assertion": "off",
+ },
+ },
prettierConfig,
);
diff --git a/package.json b/package.json
index fb7ea6c..ec754be 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,9 @@
"version": "0.0.1",
"type": "module",
"workspaces": ["apps/backend", "packages/templates", "packages/shared", "packages/example-plugin"],
- "scripts": {},
+ "scripts": {
+ "dev": "npm run build --workspaces && npm run dev --workspace apps/backend"
+ },
"keywords": [],
"author": "",
"license": "ISC",
diff --git a/packages/shared/.swcrc b/packages/shared/.swcrc
new file mode 100644
index 0000000..748234d
--- /dev/null
+++ b/packages/shared/.swcrc
@@ -0,0 +1,8 @@
+{
+ "jsc": {
+ "parser": {
+ "syntax": "typescript",
+ "tsx": false
+ }
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 1d9e537..d78abf9 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -3,11 +3,14 @@
"version": "0.0.1",
"type": "module",
"exports": {
- ".": "./src/index.ts",
- "./types": "./src/types.ts"
+ "./types": "./types.ts",
+ "./utils": {
+ "import": "./dist/src/utils.js",
+ "types": "./src/utils.ts"
+ }
},
"scripts": {
- "build": "echo \"Skipping build for 'shared' package\"",
+ "build": "swc ./src/ -d ./dist --config-file .swcrc",
"tsc": "tsc -p tsconfig.json",
"lint": "eslint ./src",
"lint:fix": "eslint ./src --fix",
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
deleted file mode 100644
index 01a9acd..0000000
--- a/packages/shared/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Export types
-export type * from "./types";
diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts
new file mode 100644
index 0000000..e358bae
--- /dev/null
+++ b/packages/shared/src/utils.ts
@@ -0,0 +1,13 @@
+/**
+ * Can be used in Array.filter to filter all undefined items out in a way
+ * typecsript understands
+ *
+ * @example
+ * ```ts
+ * const myArrayWithUndefined = [1, undefined, 2];
+ * const myArray: number[] = myArrayWithUndefined.filter(isNotUndefined);
+ * // myArray = [1, 2]
+ * ```
+ */
+export const isNotUndefined = (arg: T | undefined): arg is T =>
+ arg !== undefined;
diff --git a/packages/shared/src/types.ts b/packages/shared/types.ts
similarity index 87%
rename from packages/shared/src/types.ts
rename to packages/shared/types.ts
index e977a27..c59ae8e 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/types.ts
@@ -1,13 +1,14 @@
import "gsap";
import * as fabric from "fabric/node";
-export interface VideoOverlay {
+export interface ProgrammeInfo {
title: string;
subtitle?: string;
episode?: string;
description?: string;
iconUrl?: string;
start?: Date;
+ end?: Date;
}
type FontStyle = "normal" | "italic" | "oblique";
@@ -21,7 +22,7 @@ export type GetFontProperties = (
export type ConverterFunc = (val: number) => number;
export type FabricTemplate = (
- overlay: VideoOverlay,
+ programmes: [ProgrammeInfo, ...ProgrammeInfo[]],
helpers: {
getFontProperties: GetFontProperties;
convertX: ConverterFunc;
@@ -31,7 +32,7 @@ export type FabricTemplate = (
fabricInstance: typeof fabric,
canvas: fabric.StaticCanvas,
anim: gsap.core.Timeline,
-) => void;
+) => Promise;
export type BumpGenPlugin = (
registerTemplate: (name: string, template: FabricTemplate) => void,
diff --git a/packages/templates/src/centre-title-and-time.ts b/packages/templates/src/centre-title-and-time.ts
index 46d2227..dcdaf07 100644
--- a/packages/templates/src/centre-title-and-time.ts
+++ b/packages/templates/src/centre-title-and-time.ts
@@ -2,13 +2,15 @@ import type { BumpGenPlugin, FabricTemplate } from "bumpgen-shared/types";
import type { FabricObject } from "fabric";
export const centreTitleAndTime: FabricTemplate =
- (overlay, { convertX, convertY, getFontProperties }) =>
- (fabric, canvas) => {
+ (programmes, { convertX, convertY, getFontProperties }) =>
+ async (fabric, canvas) => {
+ const nextUp = programmes[0];
+
const textGroupObjects: FabricObject[] = [];
const fontProperties = getFontProperties("Poppins");
- if (overlay.start) {
- const startString = overlay.start?.toLocaleTimeString("en-UK", {
+ if (nextUp.start) {
+ const startString = nextUp.start?.toLocaleTimeString("en-UK", {
hour: "numeric",
minute: "numeric",
second: undefined,
@@ -27,9 +29,9 @@ export const centreTitleAndTime: FabricTemplate =
}
const timeBounding = textGroupObjects[0]?.getBoundingRect();
- const titleText = overlay.episode
- ? `${overlay.title} | ${overlay.episode}`
- : overlay.title;
+ const titleText = nextUp.episode
+ ? `${nextUp.title} | ${nextUp.episode}`
+ : nextUp.title;
const title = new fabric.FabricText(titleText, {
...fontProperties,
originX: "center",
@@ -38,9 +40,13 @@ export const centreTitleAndTime: FabricTemplate =
fill: "#ffffff",
});
+ if (title.getBoundingRect().width > convertX(0.9)) {
+ title.scale(Math.max(convertX(0.9) / title.getBoundingRect().width, 0.5));
+ }
+
textGroupObjects.push(title);
- if (overlay.start) {
+ if (nextUp.start) {
const titleBounding = title.getBoundingRect();
const line = new fabric.Rect({
left: titleBounding.left,
diff --git a/packages/templates/src/left-panel-info.ts b/packages/templates/src/left-panel-info.ts
index 9443052..0fe961c 100644
--- a/packages/templates/src/left-panel-info.ts
+++ b/packages/templates/src/left-panel-info.ts
@@ -1,10 +1,108 @@
-import type { BumpGenPlugin } from "bumpgen-shared/types";
+import type { BumpGenPlugin, FabricTemplate } from "bumpgen-shared/types";
+import type { FabricObject } from "fabric";
-// export const leftPanelInfoTemplate: FabricTemplate = (overlay, convertX, convertY) =>
-// (fabric, canvas, anim) => {
+export const leftPanelInfo: FabricTemplate =
+ (programmes, { convertX, convertY, getFontProperties }) =>
+ async (fabric, canvas) => {
+ const nextUp = programmes[0];
+ const fontProperties = getFontProperties("Poppins");
-// }
+ const convertPanelX = (no: number) => convertX(0.4) * no;
+ const paddingPanelX = convertPanelX(0.075);
+ const innerPanelWidth = convertPanelX(1) - paddingPanelX * 2;
-export const load: BumpGenPlugin = (/* registerTemplate */) => {
- // registerTemplate('left-panel-info', leftPanelInfoTemplate)
+ const panel = new fabric.Rect({
+ top: 0,
+ left: 0,
+ width: convertPanelX(1),
+ height: convertY(1),
+ fill: "#008a91",
+ // backgroundColor: '#008a91',
+ });
+
+ canvas.add(panel);
+
+ const stack: { padding: number; object: FabricObject }[] = [];
+
+ if (nextUp.iconUrl) {
+ const icon = await fabric.FabricImage.fromURL(nextUp.iconUrl);
+ const scaleRatio = convertPanelX(0.33) / icon.width;
+ icon.scale(scaleRatio);
+ // icon.width = convertPanelX(0.33);
+ // icon.height = icon.height * scaleRatio;
+ stack.push({ padding: 50, object: icon });
+ }
+
+ if (nextUp.start) {
+ const startString = nextUp.start?.toLocaleTimeString("en-UK", {
+ hour: "numeric",
+ minute: "numeric",
+ second: undefined,
+ });
+ const endString = nextUp.end?.toLocaleTimeString("en-UK", {
+ hour: "numeric",
+ minute: "numeric",
+ second: undefined,
+ });
+ const timeText = new fabric.Textbox(
+ endString ? `${startString} - ${endString}` : startString,
+ {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 40,
+ fill: "#ffffff",
+ },
+ );
+ stack.push({ padding: 10, object: timeText });
+ }
+
+ const titleText = new fabric.Textbox(nextUp.title, {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 50,
+ fill: "#ffffff",
+ });
+ stack.push({ padding: 20, object: titleText });
+
+ const getEpisodeText = (): string | undefined => {
+ if (nextUp.subtitle && nextUp.episode)
+ return `${nextUp.episode} | ${nextUp.subtitle}`;
+ else {
+ return nextUp.subtitle ?? nextUp.episode;
+ }
+ };
+ const episodeTextStr = getEpisodeText();
+
+ if (episodeTextStr !== undefined) {
+ const episodeText = new fabric.Textbox(episodeTextStr, {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 30,
+ fill: "#ffffff",
+ });
+ stack.push({ padding: 20, object: episodeText });
+ }
+
+ if (nextUp.description) {
+ const descriptionText = new fabric.Textbox(nextUp.description, {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 24,
+ fill: "#ffffff",
+ });
+ stack.push({ padding: 0, object: descriptionText });
+ }
+
+ let rollingHeight = convertY(0.05);
+ for (const { padding, object } of stack) {
+ object.setY(rollingHeight);
+ object.setX(paddingPanelX);
+ canvas.add(object);
+
+ rollingHeight += padding + object.getBoundingRect().height;
+ }
+ };
+
+export const load: BumpGenPlugin = (registerTemplate) => {
+ registerTemplate("left-panel-info", leftPanelInfo);
};
diff --git a/packages/templates/src/left-panel-next-five.ts b/packages/templates/src/left-panel-next-five.ts
new file mode 100644
index 0000000..bc0e351
--- /dev/null
+++ b/packages/templates/src/left-panel-next-five.ts
@@ -0,0 +1,102 @@
+import type { BumpGenPlugin, FabricTemplate } from "bumpgen-shared/types";
+import type { FabricObject } from "fabric/node";
+
+export const leftPanelNextFive: FabricTemplate =
+ (programmes, { convertX, convertY, getFontProperties }) =>
+ async (fabric, canvas) => {
+ const fontProperties = getFontProperties("Poppins");
+
+ const convertPanelX = (no: number) => convertX(0.4) * no;
+ const paddingPanelX = convertPanelX(0.075);
+ const innerPanelWidth = convertPanelX(1) - paddingPanelX * 2;
+
+ const panel = new fabric.Rect({
+ top: 0,
+ left: 0,
+ width: convertPanelX(1),
+ height: convertY(1),
+ fill: "#008a91",
+ });
+
+ canvas.add(panel);
+
+ const stack: { padding: number; object: FabricObject }[] = [];
+
+ stack.push({
+ object: new fabric.FabricText("Next up", {
+ ...getFontProperties("Poppins", "bold"),
+ width: innerPanelWidth,
+ fontSize: 50,
+ fill: "#ffffff",
+ }),
+ padding: 30,
+ });
+
+ for (const programme of programmes.slice(
+ 0,
+ Math.min(programmes.length, 5),
+ )) {
+ if (programme.start) {
+ const startString = programme.start?.toLocaleTimeString("en-UK", {
+ hour: "numeric",
+ minute: "numeric",
+ second: undefined,
+ });
+ const endString = programme.end?.toLocaleTimeString("en-UK", {
+ hour: "numeric",
+ minute: "numeric",
+ second: undefined,
+ });
+ const timeText = new fabric.Textbox(
+ endString ? `${startString} - ${endString}` : startString,
+ {
+ ...getFontProperties("Poppins", "light"),
+ width: innerPanelWidth,
+ fontSize: 30,
+ fill: "#ffffff",
+ },
+ );
+ stack.push({ padding: 0, object: timeText });
+ }
+
+ const titleText = new fabric.Textbox(programme.title, {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 40,
+ fill: "#ffffff",
+ });
+ stack.push({ padding: 4, object: titleText });
+
+ const getEpisodeText = (): string | undefined => {
+ if (programme.subtitle && programme.episode)
+ return `${programme.episode} | ${programme.subtitle}`;
+ else {
+ return programme.subtitle ?? programme.episode;
+ }
+ };
+ const episodeTextStr = getEpisodeText();
+
+ if (episodeTextStr !== undefined) {
+ const episodeText = new fabric.Textbox(episodeTextStr, {
+ ...fontProperties,
+ width: innerPanelWidth,
+ fontSize: 30,
+ fill: "#ffffff",
+ });
+ stack.push({ padding: 28, object: episodeText });
+ }
+ }
+
+ let rollingHeight = convertY(0.05);
+ for (const { padding, object } of stack) {
+ object.setY(rollingHeight);
+ object.setX(paddingPanelX);
+ canvas.add(object);
+
+ rollingHeight += padding + object.getBoundingRect().height;
+ }
+ };
+
+export const load: BumpGenPlugin = (registerTemplate) => {
+ registerTemplate("left-panel-next-five", leftPanelNextFive);
+};