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. + + + + + + + + + + + + + + + + + + + +
NameExample
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); +};