diff --git a/packages/image-generator/1.core.ts b/packages/image-generator/1.core.ts deleted file mode 100644 index c9fc45b..0000000 --- a/packages/image-generator/1.core.ts +++ /dev/null @@ -1,224 +0,0 @@ -import sharp from 'sharp' -import fs from 'fs' -import {optimalName} from './utils/name.js' -import {speakerSvg} from './utils/svg.js' - -/** - * @module - * this is the core internals of image creation - */ - -export interface Speaker { - profileImage?: string - buffer?: Buffer - name: string | string[] -} - -// Reads and verifies the template file exists, then returns its buffer -function readTemplateFile(templatePath: string): Buffer { - if (!fs.existsSync(templatePath)) { - throw new Error(`Template file does not exist at path: ${templatePath}`) - } - return fs.readFileSync(templatePath) -} - -// Generates SVG for speaker names -function generateSpeakerNameSvg(name: string | string[], width: number, yOffset: number, fontSize: string): string {} - -// Resizes and masks speaker images into circles -async function processSpeakerImage( - defaultSpeakerImageBuffer: Buffer, - speaker: Speaker, - width: number, - yOffset: number, - gapWidth: number, - startLeft: number, - index: number, -): Promise { - let speakerImageBuffer: Buffer - if (speaker.buffer) { - speakerImageBuffer = speaker.buffer - } else if (speaker.profileImage) { - if (!fs.existsSync(speaker.profileImage)) { - throw new Error(`Speaker image file does not exist at path: ${speaker.profileImage}`) - } - speakerImageBuffer = fs.readFileSync(speaker.profileImage) - } else { - speakerImageBuffer = defaultSpeakerImageBuffer - } - - const resizedImage = sharp(speakerImageBuffer) - .resize({ - width: width, - height: width, - }) - .toFormat('png') - - const circleSvg = `` - const circleBuffer = Buffer.from(circleSvg) - - const left = startLeft + width * index + gapWidth * index - - return { - input: await resizedImage.composite([{input: circleBuffer, blend: 'dest-in'}]).toBuffer(), - top: yOffset, - left: left, - } -} - -// Creates the date SVG -function createDateSvg(date: string, width: string, height: string, fontColor: string, fontSize: string): Buffer { - const dateSvg = `${date}` - return Buffer.from(dateSvg) -} - -export type Options = { - templatePath: string - defaultProfilePath: string - speakers: Speaker[] - outputPath?: string - speakerImageYPctOffset: number - speakerImagePctGapWidth: number - speakerImagePctMaxWidth: number - dateDay: string - dateMonth: string - dateDayTop: number - dateDayLeft: number - dateMonthTop: number - dateMonthLeft: number - timeTop: number - timeLeft: number -} - -function resolveSpeakerImage(speaker: Speaker, defaultSpeakerImageBuffer: Buffer) { - let speakerImageBuffer: Buffer - if (speaker.buffer) { - speakerImageBuffer = speaker.buffer - } else if (speaker.profileImage) { - if (!fs.existsSync(speaker.profileImage)) { - throw new Error(`Speaker image file does not exist at path: ${speaker.profileImage}`) - } - speakerImageBuffer = fs.readFileSync(speaker.profileImage) - } else { - speakerImageBuffer = defaultSpeakerImageBuffer - } - return speakerImageBuffer -} - -async function bufferToBase64(buffer: Buffer, width: number) { - const resizedImageBuf = await sharp(buffer) - .resize({ - width: width, - height: width, - }) - .toFormat('png') - .toBuffer() - - return `data:image/png;base64,${resizedImageBuf.toString('base64')}` -} - -export async function build(options: Options) { - const { - templatePath, - defaultProfilePath, - speakers, - outputPath, - speakerImageYPctOffset, - speakerImagePctGapWidth, - speakerImagePctMaxWidth, - dateDay, - dateMonth, - dateDayTop, - dateDayLeft, - dateMonthTop, - dateMonthLeft, - timeTop, - timeLeft, - } = options - try { - const templateBuffer = readTemplateFile(templatePath) - let template = sharp(templateBuffer) - const {width: templateWidth, height: templateHeight} = await template.metadata() - - if (!templateWidth || !templateHeight) { - throw new Error('Template image dimensions are undefined') - } - - const defaultUser = { - name: ['Want to present?', 'DM Us'], - } - - if (speakers.length <= 2) { - speakers.push(defaultUser) - } - - const speakerImageYOffset = Math.floor((templateHeight * speakerImageYPctOffset) / 100) - const speakerImageGapWidth = Math.floor((templateWidth * speakerImagePctGapWidth) / 100 / (speakers.length + 1)) - const speakerImageMaxWidth = Math.floor((templateWidth * speakerImagePctMaxWidth) / 100) - - const availableWidth = templateWidth - speakerImageGapWidth * (speakers.length + 1) - const initalSpeakerWidth = Math.floor(availableWidth / speakers.length) - const speakerWidth = Math.min(initalSpeakerWidth, speakerImageMaxWidth) - - // Calculate the total width of all speakers and gaps - const totalSpeakersWidth = speakerWidth * speakers.length + speakerImageGapWidth * (speakers.length - 1) - // Calculate the starting left position to center the block - const startLeft = Math.floor((templateWidth - totalSpeakersWidth) / 2) - - const defaultSpeakerImageBuffer = fs.readFileSync(defaultProfilePath) - - const speakerImages = await Promise.all( - speakers.map(async (speaker, index) => { - const buffer = resolveSpeakerImage(speaker, defaultSpeakerImageBuffer) - const base64 = await bufferToBase64(buffer, speakerWidth) - const padding = 100 - - const svg = speakerSvg({ - imageSize: speakerWidth, - xPadding: padding, - lines: Array.isArray(speaker.name) ? speaker.name : optimalName(speaker.name), - image: base64, - fontSize: 31, - fontColor: 'white', - lineHeight: 29, - bottomPadding: 10, - imageBottomPadding: 10, - }) - - return { - input: Buffer.from(svg), - top: speakerImageYOffset, - left: startLeft - padding + index * (speakerWidth + speakerImageGapWidth), - } - }), - ) - - const dateOverlay = { - input: createDateSvg(dateDay, '250px', '300px', 'white', '80px'), - top: dateDayTop, - left: dateDayLeft, - } - - const dateMonthOverlay = { - input: createDateSvg(dateMonth, '250px', '300px', 'white', '60px'), - top: dateMonthTop, - left: dateMonthLeft, - } - - const time = { - input: createDateSvg('7pm', '250px', '300px', 'black', '60px'), - top: timeTop, - left: timeLeft, - } - - const compositeLayers = [...speakerImages, dateOverlay, dateMonthOverlay, time] - - if (outputPath) { - await template.composite(compositeLayers).toFile(outputPath) - } else { - process.stdout.write(await template.composite(compositeLayers).toBuffer()) - } - } catch (error) { - console.error('Error generating event image:', error.message) - } -} diff --git a/packages/image-generator/2.fetch.ts b/packages/image-generator/2.fetch.ts deleted file mode 100644 index 4791111..0000000 --- a/packages/image-generator/2.fetch.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from 'fs/promises' -import mime from 'mime-types' -import {build as _build, type Speaker as _Speaker, type Options as _Options} from './1.core.js' -import path from 'path' - -/** - * @module - * this is a layer that fetches iamges from the - * internet the core doesn't care about the images - * themselves, just recieves buffer - */ - -interface Speaker extends _Speaker { - githubUsername?: string -} - -export interface Options extends _Options { - speakers: Speaker[] - clearCache?: boolean -} - -async function fetchImage(url: string, speakerName: string | string[], clearCache: boolean): Promise { - const slug = [speakerName].flat().join('').toLowerCase().replace(/\s/g, '') - const speakerImagesDir = path.join(import.meta.dirname, '../..', 'public/speakers') - const files = await fs.readdir(speakerImagesDir) - const existingFile = files.find(file => file.startsWith(slug)) - if (existingFile && clearCache !== true) { - return await fs.readFile(path.join(speakerImagesDir, existingFile)) - } - const response = await fetch(url) - const contentType = response.headers.get('content-type') - const extensionFromMime = mime.extension(contentType) - const extension = contentType ? extensionFromMime : url.split('.').pop() - const fileName = `${slug}.${extension}` - const location = path.join(speakerImagesDir, fileName) - const buffer = await response.arrayBuffer() - const nodeBuffer = Buffer.from(buffer) - await fs.writeFile(location, nodeBuffer) - return nodeBuffer -} - -const safeUrl = (url: string) => { - try { - return new URL(url) - } catch { - return undefined - } -} - -export async function build(options: Options) { - const {speakers, clearCache, ...rest} = options - // prioritixe profileImage over githubUsername - for (const speaker of speakers) { - if (safeUrl(speaker.profileImage)) { - try { - const buffer = await fetchImage(speaker.profileImage, speaker.name, clearCache) - speaker.buffer = buffer - } catch {} - } - if (!speaker.buffer) { - if (speaker.githubUsername) { - const buffer = await fetchImage(`https://github.com/${speaker.githubUsername}.png`, speaker.name, clearCache) - speaker.buffer = buffer - } - } - } - - return _build({ - speakers, - ...rest, - }) -} diff --git a/packages/image-generator/3.settings.ts b/packages/image-generator/3.settings.ts deleted file mode 100644 index 9468841..0000000 --- a/packages/image-generator/3.settings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import path from 'path' -import fs from 'fs/promises' -import {build as _build, type Options} from './2.fetch.js' -import {getSpeakerInfo} from './utils/speakers.js' -import {convertDateToObject} from './utils/date.js' - -/** - * @module - * this is a layer that gets configuration - * settigns for an image from the image settings - * type file - */ - -const getImageJSONSettings = async (image: string) => { - const file = await fs.readFile(path.join(import.meta.dirname, `./types/${image}/index.json`), 'utf8') - return JSON.parse(file) -} - -type BuildOptions = { - image: string - date: string - contentPath: string -} & Pick - -async function buildAbsolute(options: BuildOptions) { - const {image, date, contentPath, ...rest} = options - const settings = await getImageJSONSettings(image) - settings.templatePath = path.join(import.meta.dirname, './types', image, settings.templatePath) - settings.defaultProfilePath = path.join(import.meta.dirname, './types', image, settings.defaultProfilePath) - const dateObj = convertDateToObject(date) - const speakers = await getSpeakerInfo(contentPath, date) - const data = { - ...settings, - ...dateObj, - speakers, - ...rest, - } - return _build(data) -} - -type BuildRelativeOptions = Omit - -export function build(options: BuildRelativeOptions) { - return buildAbsolute({ - ...options, - contentPath: path.join(import.meta.dirname, '../../', './src/content'), - }) -} diff --git a/packages/image-generator/4.cli.ts b/packages/image-generator/4.cli.ts deleted file mode 100755 index 5165503..0000000 --- a/packages/image-generator/4.cli.ts +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env -S npx tsx - -import {Command} from 'commander' -import {build} from './3.settings.js' -import fs from 'fs/promises' -import path from 'path' - -const latestDateViaEvents = async (eventsPath: string) => { - const files = await fs.readdir(eventsPath) - const latestFile = files.sort().pop() - return latestFile.substring(0, 10) -} - -const program = new Command() - -program - .option('-i, --images ', 'Image type or comma seperated list') - .option('-d, --date ', 'Date string') - -program.parse(process.argv) - -const options = program.opts() - -const rawImagesOption = options.images && typeof options.images === 'string' ? options.images : undefined -let date = options.date && typeof options.date === 'string' ? options.date : undefined - -let images = rawImagesOption ? rawImagesOption.split(',').map(v => v.trim()) : [] - -if (images.length === 0) { - images = await fs.readdir(path.join(import.meta.dirname, './types')) -} - -if (!date) { - date = await latestDateViaEvents(path.join(import.meta.dirname, '../../', 'src/content/events')) -} - -const outDir = path.join(import.meta.dirname, '../../', 'public') - -images.forEach(image => { - if (image && date) { - const outputPath = `${outDir}/${image}s/${date}.png` - build({image, date, outputPath}) - } else { - console.error('Both image and date flags are required.') - } -}) diff --git a/packages/image-generator/package.json b/packages/image-generator/package.json index de8851b..f2f97a3 100644 --- a/packages/image-generator/package.json +++ b/packages/image-generator/package.json @@ -4,7 +4,7 @@ "description": "", "bin": "4.cli.ts", "scripts": { - "cli": "./4.cli.ts" + "cli": "./src/cli.ts" }, "type": "module", "keywords": [], diff --git a/packages/image-generator/types/banner/default.png b/packages/image-generator/shared-assets/default.png similarity index 100% rename from packages/image-generator/types/banner/default.png rename to packages/image-generator/shared-assets/default.png diff --git a/packages/image-generator/src/cli.ts b/packages/image-generator/src/cli.ts new file mode 100755 index 0000000..59af0f9 --- /dev/null +++ b/packages/image-generator/src/cli.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env -S npx tsx +import {Command} from 'commander' +import fs from 'fs/promises' +import path from 'path' +import {relativeDirectory} from './event-data.js' +import * as banner from '../types/banner/index.js' +import * as meetupImage from '../types/meetup-image/index.js' + +const latestDateViaEvents = async (eventsPath: string) => { + const files = await fs.readdir(eventsPath) + const latestFile = files.sort().pop() + return latestFile.substring(0, 10) +} + +async function cliOptions() { + const program = new Command() + program + .option('-i, --images ', 'Image type or comma seperated list') + .option('-d, --date ', 'Date string') + program.parse(process.argv) + + const options = program.opts() + const rawImagesOption = options.images && typeof options.images === 'string' ? options.images : undefined + let date = options.date && typeof options.date === 'string' ? options.date : undefined + let images = rawImagesOption ? rawImagesOption.split(',').map(v => v.trim()) : [] + if (images.length === 0) { + images = await fs.readdir(path.join(import.meta.dirname, '../types')) + } + if (!date) { + date = await latestDateViaEvents(relativeDirectory('src/content/events')) + } + return {date, types: images} +} + +const engineTypes = { + banner, + 'meetup-image': meetupImage, +} + +async function engine() { + const {date, types} = await cliOptions() + const cache = {} + for (const type of types) { + const t = engineTypes[type] + if (!cache[t.type]) { + cache[t.type] = await t.type(date) + } + await t.default(cache[t.type]) + } +} + +await engine() diff --git a/packages/image-generator/src/compose.ts b/packages/image-generator/src/compose.ts new file mode 100644 index 0000000..29e8a25 --- /dev/null +++ b/packages/image-generator/src/compose.ts @@ -0,0 +1,38 @@ +import fs from 'fs/promises' +import sharp from 'sharp' + +export type LayerReturn = { + input: Buffer + top: number + left: number +} + +export type Layer = ({ + width, + height, +}: { + width: number + height: number +}) => LayerReturn | Promise | undefined | Promise + +export type ComposeProps = { + background: Buffer | string + layers?: Layer[] + outputPath?: string +} + +export async function compose({background, layers, outputPath}: ComposeProps) { + const bg = typeof background === 'string' ? await fs.readFile(background) : background + const template = sharp(bg) + const {width, height} = await template.metadata() + if (!width || !height) { + throw new Error('Template image dimensions are undefined') + } + const activeLayers = await Promise.all([...layers].flat().map(l => l({width, height}))) + const activeLayersClean = activeLayers.filter(v => v).flat() + if (outputPath) { + await template.composite(activeLayersClean).toFile(outputPath) + } else { + process.stdout.write(await template.composite(activeLayersClean).toBuffer()) + } +} diff --git a/packages/image-generator/src/event-data.ts b/packages/image-generator/src/event-data.ts new file mode 100644 index 0000000..0b853fd --- /dev/null +++ b/packages/image-generator/src/event-data.ts @@ -0,0 +1,150 @@ +import fs from 'fs/promises' +import {readFile} from 'fs/promises' +import * as yaml from 'js-yaml' +import path from 'path' +import mime from 'mime-types' + +interface Event { + banner: string + date: string + meetup: string + presentations: string[] + title: string +} + +interface Presentation { + date: string + slides: string + slidesSource: string + speaker: string + title: string +} + +interface Speaker { + name: string + website?: string + githubUsername?: string + profileImage?: string + buffer?: Buffer +} + +async function parseFrontMatter(filePath: string): Promise { + const content = await readFile(filePath, 'utf8') + const match = content.match(/---\s*?\n([\s\S]*?)\n---/) + if (!match) throw new Error(`Front matter not found in ${filePath}`) + return yaml.load(match[1]) as T +} + +async function getEventData(eventFilePath: string): Promise { + return parseFrontMatter(eventFilePath) +} + +async function getPresentationData(presentationFilePath: string): Promise { + return parseFrontMatter(presentationFilePath) +} + +async function getSpeakerData(speakerFilePath: string): Promise { + return parseFrontMatter(speakerFilePath) +} + +export function relativeDirectory(root: string) { + return path.join(import.meta.dirname, '../../..', root) +} + +async function fetchImage(url: string, speakerName: string | string[], clearCache: boolean): Promise { + const slug = [speakerName].flat().join('').toLowerCase().replace(/\s/g, '') + const speakerImagesDir = relativeDirectory('public/speakers') + const files = await fs.readdir(speakerImagesDir) + const existingFile = files.find(file => file.startsWith(slug)) + if (existingFile && clearCache !== true) { + return await fs.readFile(path.join(speakerImagesDir, existingFile)) + } + const response = await fetch(url) + const contentType = response.headers.get('content-type') + const extensionFromMime = mime.extension(contentType) + const extension = contentType ? extensionFromMime : url.split('.').pop() + const fileName = `${slug}.${extension}` + const location = path.join(speakerImagesDir, fileName) + const buffer = await response.arrayBuffer() + const nodeBuffer = Buffer.from(buffer) + await fs.writeFile(location, nodeBuffer) + return nodeBuffer +} + +const safeUrl = (url: string) => { + try { + return new URL(url) + } catch { + return undefined + } +} + +async function resolveImage(props: Speaker & {clearCache?: boolean}): Promise { + const {profileImage, githubUsername, name, clearCache} = props + if (safeUrl(profileImage)) { + try { + return await fetchImage(profileImage, name, clearCache) + } catch {} + } + if (githubUsername) { + return await fetchImage(`https://github.com/${githubUsername}.png`, name, clearCache) + } +} + +export async function getEventWithSpeakers(eventFileName: string, directoryPath: string = 'src/content') { + if (!eventFileName.endsWith('.md')) eventFileName = `${eventFileName}-event.md` + const eventData = await getEventData(relativeDirectory(`${directoryPath}/events/${eventFileName}`)) + const presentations = await Promise.all( + eventData.presentations.map(async presentationFile => { + const presentationData = await getPresentationData( + relativeDirectory(`${directoryPath}/presentations/${presentationFile}.md`), + ) + const speakerData = await getSpeakerData( + relativeDirectory(`${directoryPath}/speakers/${presentationData.speaker}.md`), + ) + return {...presentationData, speaker: speakerData} + }), + ) + const speakers = await Promise.all( + presentations.map(async ({speaker}) => ({...speaker, buffer: await resolveImage(speaker)})), + ) + return {...eventData, presentations, speakers} +} + +function parseDate(dateString) { + const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] + const date = new Date(dateString) + + // Format the date as yyyy-mm-dd + const year = date.getFullYear() + const month = (date.getMonth() + 1).toString().padStart(2, '0') // Months are 0-based, so add 1 + const day = date.getDate().toString().padStart(2, '0') + + return { + id: `${year}-${month}-${day}`, + month: months[date.getMonth()], // Get the month name from the array. + day: day, + time: date.getHours() >= 12 ? `${date.getHours() % 12 || 12}pm` : `${date.getHours()}am`, // Format hours for AM/PM. + } +} + +export type SpeakersImageData = { + speakers: { + image: Buffer + name: string + }[] + date: string + month: string + day: string + time: string +} + +export async function speakersImage(date: string): Promise { + const event = await getEventWithSpeakers(date) + const speakers = event.speakers.map(({buffer, name}) => ({image: buffer, name})) + return { + date, + ...parseDate(event.date), + speakers, + } +} diff --git a/packages/image-generator/src/speaker-svg++.ts b/packages/image-generator/src/speaker-svg++.ts new file mode 100644 index 0000000..31e53b4 --- /dev/null +++ b/packages/image-generator/src/speaker-svg++.ts @@ -0,0 +1,31 @@ +import {SpeakerSvgProps as _SpeakerSvgProps, speakerSvg as _speakerSvg} from './speaker-svg+.js' + +type CalculateSpeakerOffsetProps = { + height: number + width: number + total: number + index: number + xPadding: number + offsetYPct: number + gapWidthPct: number + imageSize: number +} + +function calculateSpeakerOffset(props: CalculateSpeakerOffsetProps) { + const {index, height, width, xPadding, offsetYPct, gapWidthPct, total, imageSize} = props + const speakerWidth = imageSize + const top = Math.floor((height * offsetYPct) / 100) + const gapWidthPx = Math.floor((width * gapWidthPct) / 100 / (total - 1)) + const totalSpeakersWidth = speakerWidth * total + gapWidthPx * (total - 1) + const startLeft = Math.floor((width - totalSpeakersWidth) / 2) + const left = startLeft + ((speakerWidth + gapWidthPx) * index - xPadding) + return {left, top} +} + +export type SpeakerSvgProps = _SpeakerSvgProps & CalculateSpeakerOffsetProps + +export const speakerSvg = async (props: SpeakerSvgProps) => { + const input = await _speakerSvg(props) + const offset = calculateSpeakerOffset(props) + return {input, ...offset} +} diff --git a/packages/image-generator/src/speaker-svg+.ts b/packages/image-generator/src/speaker-svg+.ts new file mode 100644 index 0000000..918df6d --- /dev/null +++ b/packages/image-generator/src/speaker-svg+.ts @@ -0,0 +1,93 @@ +import fs from 'fs/promises' +import sharp from 'sharp' +import {SpeakerSvgProps as _SpeakerSvgProps, speakerSvg as _speakerSvg} from './speaker-svg.js' +import {relativeDirectory} from './event-data.js' + +function isUrl(url: string) { + try { + new URL(url) + return true + } catch { + return false + } +} + +const BASE64PREFIX = 'data:image/png;base64,' + +async function bufferToBase64(buffer: Buffer, width: number) { + const resizedImageBuf = await sharp(buffer) + .resize({ + width: width, + height: width, + }) + .toFormat('png') + .toBuffer() + + return `${BASE64PREFIX}${resizedImageBuf.toString('base64')}` +} +async function processImage(input: Buffer | string | undefined, imageSize: number) { + try { + if (Buffer.isBuffer(input)) { + // Input is a Buffer, convert to base64 string + return await bufferToBase64(input, imageSize) + } else if (isUrl(input)) { + // Input is a URL, fetch the image and convert to base64 + const response = await fetch(input) + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + return await bufferToBase64(buffer, imageSize) + } else if (input.startsWith(BASE64PREFIX)) { + // Input is already a base64 string, return as is + return input + } else if (typeof input === 'string') { + // Assume input is a file path, read the file and convert to base64 + const buffer = await fs.readFile(input) + return await bufferToBase64(buffer, imageSize) + } + } catch (e) {} + const buffer = await fs.readFile(relativeDirectory('packages/image-generator/shared-assets/default.png')) + return await bufferToBase64(buffer, imageSize) +} + +/** + * this exists to create the most optimal name + * break for the image, because when the name is + * too long, it will break everything + */ +const optimalName = (name: string) => { + const names = name.split(' ') + const partA = [names.shift()] + const partB = [names.pop()] + names.forEach((n, i) => { + const aLength = partA.join('').length + const bLength = partB.join('').length + if (aLength > bLength) { + partB.unshift(n) + } else { + partA.push(n) + } + }) + return [partA.join(' '), partB.join(' ')] +} + +// console.log(optimalName('Nicolas Christoher Prado')) +// console.log(optimalName('Ash Ryan Arwine')) +// console.log(optimalName('John C Reilly')) +// console.log(optimalName('Michael B Jordan')) +// console.log(optimalName('Zendaya Maree Stoermer Coleman')) +// console.log(optimalName('Julia Mimi Bella Nehdar')) +// console.log(optimalName('Christopher Robert Evans')) +// console.log(optimalName('Robert John Downey Jr')) +// console.log(optimalName('Abe B C Dolphin')) + +export type SpeakerSvgProps = Omit<_SpeakerSvgProps, 'image' | 'lines'> & { + image: string | Buffer + lines: string | string[] +} + +export async function speakerSvg(props: SpeakerSvgProps) { + const {image: _image, lines: _lines, imageSize} = props + const lines = Array.isArray(_lines) ? _lines : optimalName(_lines) + const image = await processImage(_image, imageSize) + return _speakerSvg({...props, image, lines}) +} diff --git a/packages/image-generator/src/speaker-svg.ts b/packages/image-generator/src/speaker-svg.ts new file mode 100644 index 0000000..aeef77d --- /dev/null +++ b/packages/image-generator/src/speaker-svg.ts @@ -0,0 +1,54 @@ +import {svg} from './svg.js' + +export type SpeakerSvgProps = { + imageSize: number + lines: string[] + image: string + fontSize: number + fontColor: string + lineHeight: number + xPadding?: number + bottomPadding?: number + imageBottomPadding?: number +} + +export async function speakerSvg(props: SpeakerSvgProps) { + const { + imageSize, + fontColor, + imageBottomPadding, + xPadding: x, + image, + lines, + fontSize, + lineHeight, + bottomPadding, + } = { + imageBottomPadding: 0, + fontSize: 16, + lineHeight: 20, + xPadding: 0, + ...props, + } + const y = 0 // Top position for the image + const svgHeight = imageSize + y * 2 + lines.length * lineHeight // Calculate SVG height based on image and text + + // Generate text elements based on lines array + const textElements = lines + .map( + (line, index) => + `${line}`, + ) + .join('') + + return svg` + + + + + + + ${textElements} + + ` +} diff --git a/packages/image-generator/src/speakers-svg.ts b/packages/image-generator/src/speakers-svg.ts new file mode 100644 index 0000000..168bf31 --- /dev/null +++ b/packages/image-generator/src/speakers-svg.ts @@ -0,0 +1,26 @@ +import {Layer} from './compose.js' +import {speakerSvg, SpeakerSvgProps} from './speaker-svg++.js' + +export interface Speaker { + image: string | Buffer + name: string | string[] +} + +type Core = Omit + +export type SpeakersSvgProps = {speakers: Speaker[]} & Core + +export const speakersSvg = ({speakers, ...core}: SpeakersSvgProps): Layer[] => + speakers.map( + (speaker, index, total) => + ({height, width}) => + speakerSvg({ + total: total.length, + height, + width, + index, + lines: speaker.name, + image: speaker.image, + ...core, + }), + ) diff --git a/packages/image-generator/src/svg.ts b/packages/image-generator/src/svg.ts new file mode 100644 index 0000000..727182a --- /dev/null +++ b/packages/image-generator/src/svg.ts @@ -0,0 +1,16 @@ +export const svg = (strings: TemplateStringsArray, ...values: any[]) => { + let result = strings[0] + values.forEach((value, index) => { + if (typeof value === 'object' && value !== null) { + const attributes = Object.entries(value) + .map(([key, val]) => `${key.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}="${val}"`) + .join(' ') + result += attributes + } else { + result += value + } + result += strings[index + 1] + }) + result = result.trim().replace(/^\s+|\n/gm, '') // Trim the final result + return Buffer.from(result) // Convert the trimmed string to a Buffer +} diff --git a/packages/image-generator/src/text-svg+.ts b/packages/image-generator/src/text-svg+.ts new file mode 100644 index 0000000..9743144 --- /dev/null +++ b/packages/image-generator/src/text-svg+.ts @@ -0,0 +1,13 @@ +import {LayerReturn} from './compose.js' +import {TextSvgProps as _TextSvgProps, textSvg as _textSvg} from './text-svg.js' + +export type TextSvgProps = _TextSvgProps & Omit + +export function textSvg(props?: TextSvgProps) { + return props.text + ? () => ({ + input: _textSvg(props), + ...props, + }) + : undefined +} diff --git a/packages/image-generator/src/text-svg.ts b/packages/image-generator/src/text-svg.ts new file mode 100644 index 0000000..69c79bf --- /dev/null +++ b/packages/image-generator/src/text-svg.ts @@ -0,0 +1,27 @@ +import {svg} from './svg.js' + +export type TextSvgProps = { + text: string + width: string + height: string + fontColor: string + fontSize: string +} + +export function textSvg({text, width, height, fontColor, fontSize}: TextSvgProps): Buffer { + return svg` + + + ${text} + + ` +} diff --git a/packages/image-generator/types/banner/index.json b/packages/image-generator/types/banner/index.json deleted file mode 100644 index 35a89f8..0000000 --- a/packages/image-generator/types/banner/index.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "defaultProfilePath": "default.png", - "templatePath": "template.png", - "speakerImageYPctOffset": 52, - "speakerImagePctGapWidth": 15, - "speakerImagePctMaxWidth": 25, - "dateDayTop": 110, - "dateDayLeft": 806, - "dateMonthTop": 175, - "dateMonthLeft": 806, - "timeTop": 300, - "timeLeft": 896 -} diff --git a/packages/image-generator/types/banner/index.ts b/packages/image-generator/types/banner/index.ts new file mode 100644 index 0000000..4c18149 --- /dev/null +++ b/packages/image-generator/types/banner/index.ts @@ -0,0 +1,53 @@ +import {compose} from '../../src/compose.js' +import {SpeakersImageData, relativeDirectory, speakersImage} from '../../src/event-data.js' +import {speakersSvg} from '../../src/speakers-svg.js' +import {textSvg} from '../../src/text-svg+.js' + +export const type = speakersImage + +export default ({date, speakers, day, month, time}: SpeakersImageData) => + compose({ + outputPath: relativeDirectory(`./public/banners/${date}.png`), + background: relativeDirectory(`./packages/image-generator/types/banner/template.png`), + layers: [ + textSvg({ + text: day, + width: '250px', + height: '300px', + fontColor: 'white', + fontSize: '80px', + top: 110, + left: 806, + }), + textSvg({ + text: month, + width: '250px', + height: '300px', + fontColor: 'white', + fontSize: '60px', + top: 175, + left: 806, + }), + textSvg({ + text: time, + width: '250px', + height: '300px', + fontColor: 'black', + fontSize: '60px', + top: 300, + left: 896, + }), + ...speakersSvg({ + speakers, + imageSize: 275, + fontSize: 31, + fontColor: 'white', + lineHeight: 29, + xPadding: 100, + bottomPadding: 10, + imageBottomPadding: 10, + offsetYPct: 52, + gapWidthPct: 15, + }), + ], + }) diff --git a/packages/image-generator/types/meetup-image/default.png b/packages/image-generator/types/meetup-image/default.png deleted file mode 100644 index 0d3aa97..0000000 Binary files a/packages/image-generator/types/meetup-image/default.png and /dev/null differ diff --git a/packages/image-generator/types/meetup-image/index.json b/packages/image-generator/types/meetup-image/index.json deleted file mode 100644 index 2e293f7..0000000 --- a/packages/image-generator/types/meetup-image/index.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "defaultProfilePath": "default.png", - "templatePath": "template.png", - "speakerImageYPctOffset": 58, - "speakerImagePctGapWidth": 35, - "speakerImagePctMaxWidth": 13, - "dateDayTop": 40, - "dateDayLeft": 806, - "dateMonthTop": 105, - "dateMonthLeft": 806, - "timeTop": 230, - "timeLeft": 896 -} diff --git a/packages/image-generator/types/meetup-image/index.ts b/packages/image-generator/types/meetup-image/index.ts new file mode 100644 index 0000000..567612c --- /dev/null +++ b/packages/image-generator/types/meetup-image/index.ts @@ -0,0 +1,53 @@ +import {compose} from '../../src/compose.js' +import {SpeakersImageData, relativeDirectory, speakersImage} from '../../src/event-data.js' +import {speakersSvg} from '../../src/speakers-svg.js' +import {textSvg} from '../../src/text-svg+.js' + +export const type = speakersImage + +export default ({date, speakers, day, month, time}: SpeakersImageData) => + compose({ + outputPath: relativeDirectory(`./public/meetup-images/${date}.png`), + background: relativeDirectory(`./packages/image-generator/types/meetup-image/template.png`), + layers: [ + textSvg({ + text: day, + width: '250px', + height: '300px', + fontColor: 'white', + fontSize: '80px', + top: 40, + left: 806, + }), + textSvg({ + text: month, + width: '250px', + height: '300px', + fontColor: 'white', + fontSize: '60px', + top: 105, + left: 806, + }), + textSvg({ + text: time, + width: '250px', + height: '300px', + fontColor: 'black', + fontSize: '60px', + top: 230, + left: 896, + }), + ...speakersSvg({ + speakers, + fontSize: 31, + fontColor: 'white', + offsetYPct: 58, + imageSize: 190, + lineHeight: 29, + xPadding: 100, + bottomPadding: 10, + imageBottomPadding: 10, + gapWidthPct: 10, + }), + ], + }) diff --git a/packages/image-generator/utils/date.ts b/packages/image-generator/utils/date.ts deleted file mode 100644 index c1b6038..0000000 --- a/packages/image-generator/utils/date.ts +++ /dev/null @@ -1,18 +0,0 @@ -export function convertDateToObject(date: string) { - // validate date is YYYY-MM-DD format - const dateRegex = /^\d{4}-\d{2}-\d{2}$/ - if (!dateRegex.test(date)) { - throw new Error('Invalid date format') - } - // parse date - const [_year, month, day] = date.split('-') - - // convert month to three letter month capitalised - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - const dateMonth = months[parseInt(month) - 1].toUpperCase() - - return { - dateDay: day, - dateMonth, - } -} diff --git a/packages/image-generator/utils/name.ts b/packages/image-generator/utils/name.ts deleted file mode 100644 index 60de935..0000000 --- a/packages/image-generator/utils/name.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * this exists to create the most optimal name - * break for the image, because when the name is - * too long, it will break everything - */ -export const optimalName = (name: string) => { - const names = name.split(' ') - const partA = [names.shift()] - const partB = [names.pop()] - names.forEach((n, i) => { - const aLength = partA.join('').length - const bLength = partB.join('').length - if (aLength > bLength) { - partB.unshift(n) - } else { - partA.push(n) - } - }) - return [partA.join(' '), partB.join(' ')] -} - -// console.log(optimalName('Nicolas Christoher Prado')) -// console.log(optimalName('Ash Ryan Arwine')) -// console.log(optimalName('John C Reilly')) -// console.log(optimalName('Michael B Jordan')) -// console.log(optimalName('Zendaya Maree Stoermer Coleman')) -// console.log(optimalName('Julia Mimi Bella Nehdar')) -// console.log(optimalName('Christopher Robert Evans')) -// console.log(optimalName('Robert John Downey Jr')) -// console.log(optimalName('Abe B C Dolphin')) diff --git a/packages/image-generator/utils/speakers.ts b/packages/image-generator/utils/speakers.ts deleted file mode 100644 index dea2a5d..0000000 --- a/packages/image-generator/utils/speakers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {readFile} from 'fs/promises' -import * as yaml from 'js-yaml' - -interface Event { - banner: string - date: string - meetup: string - presentations: string[] - title: string -} - -interface Presentation { - date: string - slides: string - slidesSource: string - speaker: string - title: string -} - -interface Speaker { - name: string - website?: string - githubUsername?: string - profileImage?: string -} - -async function parseFrontMatter(filePath: string): Promise { - const content = await readFile(filePath, 'utf8') - const match = content.match(/---\s*?\n([\s\S]*?)\n---/) - if (!match) throw new Error(`Front matter not found in ${filePath}`) - return yaml.load(match[1]) as T -} - -async function getEventData(eventFilePath: string): Promise { - return parseFrontMatter(eventFilePath) -} - -async function getPresentationData(presentationFilePath: string): Promise { - return parseFrontMatter(presentationFilePath) -} - -async function getSpeakerData(speakerFilePath: string): Promise { - return parseFrontMatter(speakerFilePath) -} - -async function buildEventTree(directoryPath: string, eventFileName: string) { - if (!eventFileName.endsWith('.md')) eventFileName = `${eventFileName}-event.md` - const eventData = await getEventData(`${directoryPath}/events/${eventFileName}`) - const presentations = await Promise.all( - eventData.presentations.map(async presentationFile => { - const presentationData = await getPresentationData(`${directoryPath}/presentations/${presentationFile}.md`) - const speakerData = await getSpeakerData(`${directoryPath}/speakers/${presentationData.speaker}.md`) - return {...presentationData, speaker: speakerData} - }), - ) - - return {...eventData, presentations} -} - -export async function getSpeakerInfo(directoryPath: string, eventFileName: string) { - const tree = await buildEventTree(directoryPath, eventFileName) - return tree.presentations.map(v => v.speaker) -} diff --git a/packages/image-generator/utils/svg.ts b/packages/image-generator/utils/svg.ts deleted file mode 100644 index 6e91503..0000000 --- a/packages/image-generator/utils/svg.ts +++ /dev/null @@ -1,58 +0,0 @@ -export function speakerSvg(img: { - imageSize: number - lines: string[] - image: string - fontSize: number - fontColor: string - lineHeight: number - xPadding?: number - bottomPadding?: number - imageBottomPadding?: number -}): string { - const {imageSize, fontColor, imageBottomPadding, xPadding, lines, image, fontSize, lineHeight, bottomPadding} = { - imageBottomPadding: 0, - fontSize: 16, - lineHeight: 20, - ...img, - } - - const x = xPadding || 0 // Adjust x position by xOffset - const y = 0 // Top position for the image - const svgHeight = imageSize + y * 2 + lines.length * lineHeight // Calculate SVG height based on image and text - - // Generate text elements based on lines array - const textElements = lines - .map( - (line, index) => - `${line}`, - ) - .join('') - - return ` - - - - - - - - - - - - ${textElements} - - -` -} - -// createSvg({ -// imageSize: 100, -// xPadding : 100, -// lines: ["Nicolas Prado", "Software"], -// image, -// fontSize: 16, -// fontColor: 'white', -// lineHeight: 20, -// bottomPadding: 10, -// }) diff --git a/public/banners/2024-07-04.png b/public/banners/2024-07-04.png index e4abcc3..43cf1f7 100644 Binary files a/public/banners/2024-07-04.png and b/public/banners/2024-07-04.png differ diff --git a/public/meetup-images/2024-07-04.png b/public/meetup-images/2024-07-04.png index 37abfdb..3ad8c09 100644 Binary files a/public/meetup-images/2024-07-04.png and b/public/meetup-images/2024-07-04.png differ