diff --git a/config/bsconfig.base.json b/config/bsconfig.base.json index c6c81d5c..f3673df9 100644 --- a/config/bsconfig.base.json +++ b/config/bsconfig.base.json @@ -26,7 +26,8 @@ "source/**", "components/**", "images/**", - "!images/vector/**", + "!**/*.svg", + "!**/*.svg.meta.json5", "fonts/**", "config/**", "lib/**", @@ -48,7 +49,8 @@ "../tools/bs-plugins/track-transpiled-plugin.ts", "../tools/bs-plugins/json-yaml-plugin.ts", "../tools/bs-plugins/validation-plugin.ts", - "../tools/bs-plugins/logger-plugin.ts" + "../tools/bs-plugins/logger-plugin.ts", + "../tools/bs-plugins/image-gen-plugin.ts" ], "retainStagingDir": true, "require": [ diff --git a/config/bsconfig.tests.base.json b/config/bsconfig.tests.base.json index 1ff703a2..bf9164e4 100644 --- a/config/bsconfig.tests.base.json +++ b/config/bsconfig.tests.base.json @@ -4,7 +4,8 @@ "source/**", "components/**", "images/**", - "!images/vector/**", + "!**/*.svg", + "!**/*.svg.meta.json5", "fonts/**", "config/**", "lib/**", @@ -24,7 +25,8 @@ "../tools/bs-plugins/track-transpiled-plugin.ts", "../tools/bs-plugins/json-yaml-plugin.ts", "../tools/bs-plugins/validation-plugin.ts", - "../tools/bs-plugins/logger-plugin.ts" + "../tools/bs-plugins/logger-plugin.ts", + "../tools/bs-plugins/image-gen-plugin.ts" ], "rooibos": { "logLevel": 2, diff --git a/docs/plugins.md b/docs/plugins.md index 0f196922..5cf44f78 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,6 +1,7 @@ # Playlet Brighterscript Plugins - [Json5/Yaml support](#json5yaml-support) +- [Convert images](#convert-images) - [Manifest editing](#manifest-editing) - [Validation](#validation) - [Includes](#includes) @@ -27,6 +28,21 @@ Brightscript can only parse json natively using [ParseJson](https://developer.ro At the end of the build, the plugin scans for certain files (`jsonc`, `json`, `json5` and `yaml`), and converts them to plain json, while keeping the original file name. That way they are all parsable by the [ParseJson](https://developer.roku.com/en-ca/docs/references/brightscript/language/global-utility-functions.md#parsejsonjsonstring-as-string-flags---as-string-as-object) function, even though their original format is not compatible. +## Convert images + +**[Source](/tools/bs-plugins/image-gen-plugin.ts)** + +This plugin allows us to convert svg files to png files. + +### Why + +This is used to convert splash screen, app poster, as well as icons in the app. Having svg images (or vector images in general) as the source of truth is better, in case images need to be modified or resized. Since Roku can't load them, we convert them to png. + +### How + +Each `.svg` file found will have an associated `.svg.meta.json5` file, which will contain the hash (MD5) of the svg, conversion paramters, and the hash of the output files. This allows us to hash existing files and see if a conversion is needed. +Notice how each `.svg` file can be configured to generate multiple png files. This is especially the case to generate the app artwork (splash screen/poster) with different sizes from the same logo `.svg` file. + ## Manifest editing **[Source](/tools/bs-plugins/manifest-edit-plugin.ts)** diff --git a/package-lock.json b/package-lock.json index 983e473b..cc1be23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "devDependencies": { "@rokucommunity/bslint": "^0.8.10", + "@types/crypto-js": "^4.1.2", "@types/fs-extra": "^11.0.2", "@types/node": "^20.7.2", "@types/xml2js": "^0.4.12", @@ -18,6 +19,7 @@ "brighterscript-formatter": "^1.6.33", "convert-svg-to-png": "^0.6.4", "cross-fetch": "^4.0.0", + "crypto-js": "^4.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", "fs-extra": "^11.1.1", @@ -327,6 +329,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/crypto-js": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.2.tgz", + "integrity": "sha512-t33RNmTu5ufG/sorROIafiCVJMx3jz95bXUMoPAZcUD14fxMXnuTzqzXZoxpR0tNx2xpw11Dlmem9vGCsrSOfA==", + "dev": true + }, "node_modules/@types/fs-extra": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.2.tgz", @@ -1507,6 +1515,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==", + "dev": true + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", diff --git a/package.json b/package.json index a9de71b4..6516d73f 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Unofficial Youtube client for Roku", "devDependencies": { "@rokucommunity/bslint": "^0.8.10", + "@types/crypto-js": "^4.1.2", "@types/fs-extra": "^11.0.2", "@types/node": "^20.7.2", "@types/xml2js": "^0.4.12", @@ -12,6 +13,7 @@ "brighterscript-formatter": "^1.6.33", "convert-svg-to-png": "^0.6.4", "cross-fetch": "^4.0.0", + "crypto-js": "^4.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", "fs-extra": "^11.1.1", @@ -52,7 +54,6 @@ "test:build:app": "cd playlet-app && npm run test:build", "test:lib": "cd playlet-lib && npm run test", "test:build:lib": "cd playlet-lib && npm run test:build", - "generate-images": "node tools/generate-images.js", "version-sync": "node tools/version-sync.js", "manifest-git-hash": "node tools/update-manifest-git-hash.js", "sign-package": "node tools/sign-package.js", diff --git a/playlet-app/src/images/vector/logo-dark.svg.meta.json5 b/playlet-app/src/images/vector/logo-dark.svg.meta.json5 new file mode 100644 index 00000000..4562de4d --- /dev/null +++ b/playlet-app/src/images/vector/logo-dark.svg.meta.json5 @@ -0,0 +1,26 @@ +{ + inputHash: '0536f51fa72c2e4e368ea0e901aadfc1', + outputs: [ + { + outputFilePath: 'src/images/splash-screen_fhd.jpg', + outputHash: 'b15f3a3f1ccc459cbca12cb828b78d16', + width: 1920, + height: 1080, + background: '#242424', + }, + { + outputFilePath: 'src/images/splash-screen_hd.jpg', + outputHash: '25e9c4220f8a42101d6333e548e33aa8', + width: 1280, + height: 720, + background: '#242424', + }, + { + outputFilePath: 'src/images/splash-screen_sd.jpg', + outputHash: 'c54a2f7fa65f443263806962450f2c04', + width: 720, + height: 480, + background: '#242424', + }, + ], +} \ No newline at end of file diff --git a/playlet-app/src/images/vector/logo-fav.svg.meta.json5 b/playlet-app/src/images/vector/logo-fav.svg.meta.json5 new file mode 100644 index 00000000..4e481b7b --- /dev/null +++ b/playlet-app/src/images/vector/logo-fav.svg.meta.json5 @@ -0,0 +1,4 @@ +{ + inputHash: "d61131ccc714d8df0b4514ca76883494", + outputs: [], +} diff --git a/playlet-app/src/images/vector/logo-light.svg.meta.json5 b/playlet-app/src/images/vector/logo-light.svg.meta.json5 new file mode 100644 index 00000000..d70803aa --- /dev/null +++ b/playlet-app/src/images/vector/logo-light.svg.meta.json5 @@ -0,0 +1,26 @@ +{ + inputHash: '3452c6ea1c84f59aedf5530bf86a7a51', + outputs: [ + { + outputFilePath: 'src/images/channel-poster_fhd.png', + outputHash: 'd644a0db84deed0bcc41a41d08a4ef75', + width: 540, + height: 405, + background: '#FFFFFF', + }, + { + outputFilePath: 'src/images/channel-poster_hd.png', + outputHash: 'd07a4ffaa021b55528bee91710f4e9d2', + width: 290, + height: 218, + background: '#FFFFFF', + }, + { + outputFilePath: 'src/images/channel-poster_sd.png', + outputHash: '66a311c639f89ff009e4624468282b2f', + width: 246, + height: 140, + background: '#FFFFFF', + }, + ], +} \ No newline at end of file diff --git a/playlet-app/src/manifest b/playlet-app/src/manifest index 3eb79114..16a2da4a 100644 --- a/playlet-app/src/manifest +++ b/playlet-app/src/manifest @@ -9,7 +9,7 @@ ui_resolutions=hd ## Channel Assets ### Main Menu Icons / Channel Poster Artwork -#### Image sizes are FHD: 540x405px | HD: 290x218px | SD: 214x144px +#### Image sizes are FHD: 540x405px | HD: 290x218px | SD: 246x140px mm_icon_focus_fhd=pkg:/images/channel-poster_fhd.png mm_icon_focus_hd=pkg:/images/channel-poster_hd.png mm_icon_focus_sd=pkg:/images/channel-poster_sd.png diff --git a/playlet-lib/src/images/vector/icons/filters-black.svg b/playlet-lib/src/images/icons/filters-black.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/filters-black.svg rename to playlet-lib/src/images/icons/filters-black.svg diff --git a/playlet-lib/src/images/icons/filters-black.svg.meta.json5 b/playlet-lib/src/images/icons/filters-black.svg.meta.json5 new file mode 100644 index 00000000..694d94db --- /dev/null +++ b/playlet-lib/src/images/icons/filters-black.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: 'b88a05052cd01cb7a89bd6802418d79e', + outputs: [ + { + outputFilePath: 'src/images/icons/filters-black.png', + outputHash: 'f0a7539ae840c00a2d91292f993bb75e', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/filters-white.svg b/playlet-lib/src/images/icons/filters-white.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/filters-white.svg rename to playlet-lib/src/images/icons/filters-white.svg diff --git a/playlet-lib/src/images/icons/filters-white.svg.meta.json5 b/playlet-lib/src/images/icons/filters-white.svg.meta.json5 new file mode 100644 index 00000000..04048f8a --- /dev/null +++ b/playlet-lib/src/images/icons/filters-white.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: 'df7918a3b43642f707cc0d9fcfd2d9ee', + outputs: [ + { + outputFilePath: 'src/images/icons/filters-white.png', + outputHash: '914e2c8da9772c23c24bebbd39c918f2', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/home.svg b/playlet-lib/src/images/icons/home.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/home.svg rename to playlet-lib/src/images/icons/home.svg diff --git a/playlet-lib/src/images/icons/home.svg.meta.json5 b/playlet-lib/src/images/icons/home.svg.meta.json5 new file mode 100644 index 00000000..671ecece --- /dev/null +++ b/playlet-lib/src/images/icons/home.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: '788d5e9ddc5a26953392afa17a85a05c', + outputs: [ + { + outputFilePath: 'src/images/icons/home.png', + outputHash: '3da3ed864137ace837ef2f96a4e5e13d', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/info.svg b/playlet-lib/src/images/icons/info.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/info.svg rename to playlet-lib/src/images/icons/info.svg diff --git a/playlet-lib/src/images/icons/info.svg.meta.json5 b/playlet-lib/src/images/icons/info.svg.meta.json5 new file mode 100644 index 00000000..8bb1d59b --- /dev/null +++ b/playlet-lib/src/images/icons/info.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: '92a5720049ca36c38c695969534c2bb5', + outputs: [ + { + outputFilePath: 'src/images/icons/info.png', + outputHash: '31b5c5c342ded1bc99b3466c2fbaaf09', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/phone.svg b/playlet-lib/src/images/icons/phone.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/phone.svg rename to playlet-lib/src/images/icons/phone.svg diff --git a/playlet-lib/src/images/icons/phone.svg.meta.json5 b/playlet-lib/src/images/icons/phone.svg.meta.json5 new file mode 100644 index 00000000..bf38513b --- /dev/null +++ b/playlet-lib/src/images/icons/phone.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: 'e6318f56ce4c0cc637cf0cc3180a2013', + outputs: [ + { + outputFilePath: 'src/images/icons/phone.png', + outputHash: '2d7ece94b64fa5a725adeea5a313cbcf', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/search.svg b/playlet-lib/src/images/icons/search.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/search.svg rename to playlet-lib/src/images/icons/search.svg diff --git a/playlet-lib/src/images/icons/search.svg.meta.json5 b/playlet-lib/src/images/icons/search.svg.meta.json5 new file mode 100644 index 00000000..428f93b1 --- /dev/null +++ b/playlet-lib/src/images/icons/search.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: 'dede98d4bf3c835836c82c485600108c', + outputs: [ + { + outputFilePath: 'src/images/icons/search.png', + outputHash: '5b5fb4128202a1e074b04ee570bbcf69', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/playlet-lib/src/images/vector/icons/settings.svg b/playlet-lib/src/images/icons/settings.svg similarity index 100% rename from playlet-lib/src/images/vector/icons/settings.svg rename to playlet-lib/src/images/icons/settings.svg diff --git a/playlet-lib/src/images/icons/settings.svg.meta.json5 b/playlet-lib/src/images/icons/settings.svg.meta.json5 new file mode 100644 index 00000000..960cfaea --- /dev/null +++ b/playlet-lib/src/images/icons/settings.svg.meta.json5 @@ -0,0 +1,11 @@ +{ + inputHash: '11d4a0bfa1ae239216088d825851ec4a', + outputs: [ + { + outputFilePath: 'src/images/icons/settings.png', + outputHash: '1b6d024df95af8b002e469453e29a632', + width: 64, + height: 64, + }, + ], +} \ No newline at end of file diff --git a/tools/bs-plugins/image-gen-plugin.ts b/tools/bs-plugins/image-gen-plugin.ts new file mode 100644 index 00000000..acff95cf --- /dev/null +++ b/tools/bs-plugins/image-gen-plugin.ts @@ -0,0 +1,107 @@ +// This plugin converts svg files to png/jpeg files + +import { + CompilerPlugin, ProgramBuilder, +} from 'brighterscript'; +import { readFileSync, renameSync, rmSync, writeFileSync } from 'fs'; +import { existsSync } from 'fs-extra'; +import { globSync } from 'glob'; +import md5 from 'crypto-js/md5'; +import { join as joinPath, relative as relativePath } from 'path'; +import json5 from 'json5'; +const shell = require('shelljs'); + +const META_EXT = '.meta.json5'; + +export class ImageGenPlugin implements CompilerPlugin { + public name = 'ImageGenPlugin'; + + beforeProgramCreate(builder: ProgramBuilder) { + const rootDir = builder.options.rootDir!; + + const svgFiles = globSync(`**/*.svg`, { cwd: rootDir }); + + svgFiles.forEach((svg) => { + // Web app can use svg files, no need to convert + if (svg.includes('www')) { + return; + } + + const svgFile = relativePath(process.cwd(), joinPath(rootDir, svg)); + const metafile = `${svgFile}${META_EXT}`; + if (!existsSync(metafile)) { + this.createDefaultMetaFile(svgFile, metafile); + } + + const meta = json5.parse(readFileSync(metafile, 'utf8')); + + this.generateImages(svgFile, meta, metafile); + }); + } + + createDefaultMetaFile(svgFile: string, metafile: string) { + const meta = { + inputHash: '', + outputs: [{ + outputFilePath: svgFile.replace('.svg', '.png'), + outputHash: '', + width: 64, + height: 64, + }] + } + + writeFileSync(metafile, json5.stringify(meta, null, 2)); + } + + generateImages(svgFile: string, meta: any, metafile: string) { + let metaChanged = false; + const inputHash = this.checkFileHash(svgFile, meta.inputHash); + if (!inputHash.valid) { + meta.inputHash = inputHash.hash; + metaChanged = true; + } + + for (var i in meta.outputs) { + const output = meta.outputs[i]; + + let outputHash = this.checkFileHash(output.outputFilePath, output.outputHash); + + if (inputHash.valid && outputHash.valid) { + continue; + } + + this.generateImage(svgFile, output); + outputHash = this.checkFileHash(output.outputFilePath, output.outputHash); + + output.outputHash = outputHash.hash; + metaChanged = true; + } + + if (metaChanged) { + writeFileSync(metafile, json5.stringify(meta, null, 2)); + } + } + + generateImage(svgFile: string, output: any) { + shell.exec(`node ../tools/convert-image.js --input "${svgFile}" --options '${JSON.stringify(output)}'`) + } + + checkFileHash(file: string, hash: string) { + if (!existsSync(file)) { + return { + valid: false, + hash: '' + } + } + + const outputHash = md5(readFileSync(file, 'binary')).toString(); + return { + valid: outputHash === hash, + hash: outputHash + } + } +} + +export default () => { + return new ImageGenPlugin(); +}; diff --git a/tools/convert-image.js b/tools/convert-image.js new file mode 100644 index 00000000..ff89ff3a --- /dev/null +++ b/tools/convert-image.js @@ -0,0 +1,19 @@ +// Description: Converts SVGs to PNGs and resizes them to the correct dimensions. + +const { ArgumentParser } = require('argparse') +const { convertFile } = require('convert-svg-to-png'); + +(async () => { + const parser = new ArgumentParser({ + description: 'Sync Youtube profile with Invidious' + }); + + parser.add_argument('--input', { help: 'Input file path' }); + parser.add_argument('--options', { help: 'Options as json string' }); + + const args = parser.parse_args() + const input = args.input + const options = JSON.parse(args.options) + + await convertFile(input, options); +})(); diff --git a/tools/generate-images.js b/tools/generate-images.js deleted file mode 100644 index f465542f..00000000 --- a/tools/generate-images.js +++ /dev/null @@ -1,79 +0,0 @@ -// Description: Generates images for the project. -// Converts SVGs to PNGs and resizes them to the correct dimensions. - -const fs = require('fs'); -const path = require('path'); -const { convertFile } = require('convert-svg-to-png'); - -const logoPoster = './playlet-app/src/images/vector/logo-light.svg'; -const logoSplash = './playlet-app/src/images/vector/logo-dark.svg'; - -const logoOutput = { - // Logo - "splash-screen_fhd.jpg": { - width: 1920, - height: 1080, - background: "#242424", - from: logoSplash - }, - "splash-screen_hd.jpg": { - width: 1280, - height: 720, - background: "#242424", - from: logoSplash - }, - "splash-screen_sd.jpg": { - width: 720, - height: 480, - background: "#242424", - from: logoSplash - }, - "channel-poster_fhd.png": { - width: 540, - height: 405, - background: "#FFFFFF", - from: logoPoster - }, - "channel-poster_hd.png": { - width: 290, - height: 218, - background: "#FFFFFF", - from: logoPoster - }, - "channel-poster_sd.png": { - width: 246, - height: 140, - background: "#FFFFFF", - from: logoPoster - }, -}; - -const iconsInput = './playlet-lib/src/images/vector/icons'; -const iconsOutput = './playlet-lib/src/images/icons'; - -(async () => { - for (var logo in logoOutput) { - await convertFile(logoOutput[logo].from, { - outputFilePath: './playlet-app/src/images/' + logo, - background: logoOutput[logo].background, - height: logoOutput[logo].height, - width: logoOutput[logo].width, - }); - - console.log(`Generated ${logo}`); - } - - const iconFiles = fs.readdirSync(iconsInput); - for (var i in iconFiles) { - const input = path.join(iconsInput, iconFiles[i]); - const output = path.join(iconsOutput, iconFiles[i].replace('.svg', '.png')); - - await convertFile(input, { - outputFilePath: output, - width: 64, - height: 64, - }); - - console.log(`Generated ${output}`); - } -})();