diff --git a/artwork/img/square.svg b/artwork/img/square.svg new file mode 100644 index 00000000000..e9206059893 --- /dev/null +++ b/artwork/img/square.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + intro_1 + intro_2 + + + 1title_1 + 2title_1 + 2title_2 + 3title_1 + 3title_2 + 3title_3 + + + F + r + e + e + S + e + w + i + n + g + + + + diff --git a/artwork/img/tall.svg b/artwork/img/tall.svg new file mode 100644 index 00000000000..fbc960790c7 --- /dev/null +++ b/artwork/img/tall.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + intro_1 + intro_2 + + + 1title_1 + + 2title_1 + 2title_2 + + 3title_1 + 3title_2 + 3title_3 + + 4title_1 + 4title_2 + 4title_3 + 4title_4 + + 5title_1 + 5title_2 + 5title_3 + 5title_4 + 5title_5 + + + F + r + e + e + S + e + w + i + n + g + + + site + + + + diff --git a/artwork/img/wide.svg b/artwork/img/wide.svg new file mode 100644 index 00000000000..1c933139e10 --- /dev/null +++ b/artwork/img/wide.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + intro_1 + intro_2 + + + 1title_1 + 2title_1 + 2title_2 + 3title_1 + 3title_2 + 3title_3 + + + F + r + e + e + S + e + w + i + n + g + + + site + + + + diff --git a/artwork/og/template.svg b/artwork/og/template.svg deleted file mode 100644 index fcfa94b941b..00000000000 --- a/artwork/og/template.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - intro_1 - intro_2 - - - 1title_1 - 2title_1 - 2title_2 - 3title_1 - 3title_2 - 3title_3 - - - F - r - e - e - S - e - w - i - n - g - - - sub_1sub_2 - - - lead_1 - - - - - - - - - - - - diff --git a/sites/backend/package.json b/sites/backend/package.json index be6f9c86d32..353cb691834 100644 --- a/sites/backend/package.json +++ b/sites/backend/package.json @@ -30,6 +30,7 @@ "dependencies": { "@aws-sdk/client-sesv2": "3.428.0", "@prisma/client": "5.4.2", + "@vercel/og": "^0.5.20", "bcryptjs": "2.4.3", "cors": "2.8.5", "dotenv": "16.3.1", diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 3ec0513cfa0..a8fcfdad108 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -115,6 +115,39 @@ const baseConfig = { uk: crowdinProject + 'invite?h=' + process.env.BACKEND_CROWDIN_INVITE_UK, }, }, + img: { + sites: ['org', 'dev', 'social'], + templates: { + folder: ['..', '..', 'artwork', 'img'], + sizes: { + square: 2000, + tall: 1080, + wide: 2400, + }, + chars: { + wide: { + title_1: 24, + title_2: 26, + title_3: 26, + intro: 58, + }, + square: { + title_1: 20, + title_2: 20, + title_3: 20, + intro: 52, + }, + tall: { + title_1: 20, + title_2: 20, + title_3: 20, + title_4: 20, + title_5: 20, + intro: 52, + }, + }, + }, + }, jwt: { secretOrKey: encryptionKey, issuer: api, @@ -277,6 +310,7 @@ export const github = config.github export const instance = config.instance export const exports = config.exports export const oauth = config.oauth +export const imgConfig = config.img const vars = { BACKEND_DB_URL: ['required', 'db.url'], diff --git a/sites/backend/src/controllers/img.mjs b/sites/backend/src/controllers/img.mjs new file mode 100644 index 00000000000..26913877a31 --- /dev/null +++ b/sites/backend/src/controllers/img.mjs @@ -0,0 +1,167 @@ +import sharp from 'sharp' +import fs from 'fs' +import path from 'path' +import { imgConfig } from '../config.mjs' + +/* + * Load SVG templates once at startup + */ +const templates = {} +for (const type of Object.keys(imgConfig.templates.sizes)) { + templates[type] = fs.readFileSync( + path.resolve(...imgConfig.templates.folder, `${type}.svg`), + 'utf-8' + ) +} + +/* Find longest possible place to split a string */ +const splitLine = (line, chars) => { + const words = line.split(' ') + if (words[0].length > chars) { + // Force a word break + return [line.slice(0, chars - 1) + '-', line.slice(chars - 1)] + } + // Glue chunks together until it's too long + let firstLine = '' + let max = false + for (const word of words) { + if (!max && `${firstLine}${word}`.length <= chars) firstLine += `${word} ` + else max = true + } + + return [firstLine.trim(), words.join(' ').slice(firstLine.length).trim()] +} + +/* Divide title into lines to fit on image */ +const titleAsLines = (title, type) => { + // Does it fit on one line? + if (title.length <= imgConfig.templates.chars[type].title_1) return [title] + // Does it fit on two lines? + let lines = splitLine(title, imgConfig.templates.chars[type].title_1) + if (lines[1].length <= imgConfig.templates.chars[type].title_2) return lines + // Does it fit on three lines? + lines = [lines[0], ...splitLine(lines[1], imgConfig.templates.chars[type].title_2)] + if (lines[2].length <= imgConfig.templates.chars[type].title_3) return lines + // Does it fit on four lines? + lines = [lines[0], lines[1], ...splitLine(lines[2], imgConfig.templates.chars[type].title_3)] + if (lines[3].length <= imgConfig.templates.chars[type].title_4) return lines + // Five lines it is + return [ + lines[0], + lines[1], + lines[2], + ...splitLine(lines[3], imgConfig.templates.chars[type].title_4), + ] +} + +/* Divive intro into lines to fit on image */ +const introAsLines = (intro, type) => { + // Does it fit on one line? + if (intro.length <= imgConfig.templates.chars[type].intro) return [intro] + // Two lines it is + return splitLine(intro, imgConfig.templates.chars[type].intro) +} + +/* Hide unused placeholders */ +const hidePlaceholders = (list, type) => { + let svg = templates[type] + for (const i of list) { + svg = svg + .replace(`${i}title_1`, '') + .replace(`${i}title_2`, '') + .replace(`${i}title_3`, '') + .replace(`${i}title_4`, '') + .replace(`${i}title_5`, '') + } + + return svg +} + +/* Place text in SVG template */ +const decorateSvg = (data) => { + let svg + // Single title line + if (data.title.length === 1) { + svg = hidePlaceholders([2, 3, 4, 5], data.type).replace(`1title_1`, data.title[0]) + } + // Double title line + else if (data.title.length === 2) { + svg = hidePlaceholders([1, 3, 4, 5], data.type) + .replace(`2title_1`, data.title[0]) + .replace(`2title_2`, data.title[1]) + } + // Triple title line + else if (data.title.length === 3) { + svg = hidePlaceholders([1, 2, 4, 5], data.type) + .replace(`3title_1`, data.title[0]) + .replace(`3title_2`, data.title[1]) + .replace(`3title_3`, data.title[2]) + } + // Quadruple title line + else if (data.title.length === 4) { + svg = hidePlaceholders([1, 2, 3, 5], data.type) + .replace(`4title_1`, data.title[0]) + .replace(`4title_2`, data.title[1]) + .replace(`4title_3`, data.title[2]) + .replace(`4title_4`, data.title[3]) + } + // Quintuple title line + else if (data.title.length === 5) { + svg = hidePlaceholders([1, 2, 3, 4], data.type) + .replace(`5title_1`, data.title[0]) + .replace(`5title_2`, data.title[1]) + .replace(`5title_3`, data.title[2]) + .replace(`5title_4`, data.title[3]) + .replace(`5title_5`, data.title[4]) + } + + return svg + .replace(`intro_1`, data.intro[0] || '') + .replace(`intro_2`, data.intro[1] || '') + .replace('site', data.site || '') +} + +export function ImgController() {} + +/* + * Generate an Open Graph image + * See: https://freesewing.dev/reference/backend/api + */ +ImgController.prototype.generate = async (req, res, tools) => { + /* + * Extract body parameters + */ + const { + site = false, + title = 'Please provide a title', + intro = 'Please provide an intro', + type = 'wide', + } = req.body + if (site && imgConfig.sites.indexOf(site) === -1) + return res.status(400).send({ error: 'invalidSite' }) + + /* + * Preformat data for SVG template + */ + const data = { + title: titleAsLines(title, type), + intro: introAsLines(intro, type), + site: site ? 'FreeSewing.' + site : '', + type, + } + + /* + * Inject data into SVG template + */ + const svg = decorateSvg(data) + + /* + * Convert to PNG and return + */ + sharp(Buffer.from(svg, 'utf-8')) + .resize({ width: imgConfig.templates.sizes[type] }) + .toBuffer((err, data, info) => { + if (err) console.log(err) + return res.type('png').send(data) + }) +} diff --git a/sites/backend/src/routes/img.mjs b/sites/backend/src/routes/img.mjs new file mode 100644 index 00000000000..77c8b35cf53 --- /dev/null +++ b/sites/backend/src/routes/img.mjs @@ -0,0 +1,10 @@ +import { ImgController } from '../controllers/img.mjs' + +const Img = new ImgController() + +export function imgRoutes(tools) { + const { app } = tools + + // Generate an image + app.post('/img', (req, res) => Img.generate(req, res, tools)) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index 6463cfe032d..ccece981878 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -8,6 +8,7 @@ import { curatedSetsRoutes } from './curated-sets.mjs' import { optionPacksRoutes } from './option-packs.mjs' import { subscribersRoutes } from './subscribers.mjs' import { flowsRoutes } from './flows.mjs' +import { imgRoutes } from './img.mjs' import { adminRoutes } from './admin.mjs' export const routes = { @@ -21,5 +22,6 @@ export const routes = { optionPacksRoutes, subscribersRoutes, flowsRoutes, + imgRoutes, adminRoutes, } diff --git a/sites/backend/test.sh b/sites/backend/test.sh new file mode 100644 index 00000000000..d9ddedc6c5f --- /dev/null +++ b/sites/backend/test.sh @@ -0,0 +1,2 @@ +curl -d '{ "title": "I am the title", "intro": "And I am the intro", "site": "dev" }' http://localhost:3000/og + diff --git a/sites/org/components/genimg/en.yaml b/sites/org/components/genimg/en.yaml new file mode 100644 index 00000000000..6ff7ae27465 --- /dev/null +++ b/sites/org/components/genimg/en.yaml @@ -0,0 +1,19 @@ +title: Title +titleMsg: This will be the main text on the image +intro: Intro / Byline / Footer +introMsg: This will appear smaller at the bottom +type: Variant +typeMsg: Pick the variant that best suits your needs +site: Site +siteMsg: This format can optionally include the site name +generate: Generate image +generateAgain: Generate another image +preview: Preview +save: Save Image +tall: Tall +tallMsg: Generates a tall image, optimized for Instagram stories, TikTok, and other places that prefer portrait mode. +wide: Wide +wideMsg: Generates a wide image, optimized for posting on a variety of platforms including Facebook, Mastodon, Reddit, and so on. Also suitable as Open Graph image. +square: Square +squareMsg: Generate a square image optimized for Instagram posts and other places where a square aspect ratio works best. +none: None diff --git a/sites/org/components/genimg/index.mjs b/sites/org/components/genimg/index.mjs new file mode 100644 index 00000000000..4976526a745 --- /dev/null +++ b/sites/org/components/genimg/index.mjs @@ -0,0 +1,114 @@ +import { nsMerge } from 'shared/utils.mjs' +import { useState } from 'react' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useTranslation } from 'next-i18next' +import { StringInput, ListInput, ns as inputNs } from 'shared/components/inputs.mjs' + +export const ns = nsMerge('genimg', inputNs) + +const binaryToData = (binary) => { + const img = new Image() +} + +export const GenerateImage = () => { + const backend = useBackend() + const { t } = useTranslation(ns) + + const [title, setTitle] = useState('') + const [intro, setIntro] = useState('') + const [type, setType] = useState('tall') + const [site, setSite] = useState(false) + const [preview, setPreview] = useState(false) + + const generate = async () => { + let result + try { + result = await backend.img({ title, intro, type, site }) + if (result.success) { + const uint8Array = new Uint8Array(result.data) + const base64String = btoa(String.fromCharCode.apply(null, uint8Array)) + setPreview(`data:image/png;base64,${base64String}`) + } + } catch (err) { + console.log(err) + } + } + + return ( +
+ {preview ? ( + <> + + + + ) : ( + <> + true} + /> + true} + /> + + {type === 'wide' ? ( + + ) : null} + + + )} +
+ ) +} diff --git a/sites/org/pages/new/img.mjs b/sites/org/pages/new/img.mjs new file mode 100644 index 00000000000..09bed0e40ae --- /dev/null +++ b/sites/org/pages/new/img.mjs @@ -0,0 +1,43 @@ +// Dependencies +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' +// Hooks +import { useTranslation } from 'next-i18next' +// Components +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' +import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' +import { BareLayout } from 'site/components/layouts/bare.mjs' +import { GenerateImage, ns as genImgNs } from 'site/components/genimg/index.mjs' + +// Translation namespaces used on this page +const ns = nsMerge(authNs, pageNs, genImgNs, 'account') + +/* + * Each page MUST be wrapped in the PageWrapper component. + * You also MUST spread props.page into this wrapper component + * when path and locale come from static props (as here) + * or set them manually. + */ +const NewBlogPage = ({ page }) => { + const { t } = useTranslation(ns) + + return ( + + + + ) +} + +export default NewBlogPage + +export async function getStaticProps({ locale }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + page: { + locale, + path: ['new', 'img'], + }, + }, + } +} diff --git a/sites/org/pages/new/index.mjs b/sites/org/pages/new/index.mjs index d2ca6fed6db..09c4163633e 100644 --- a/sites/org/pages/new/index.mjs +++ b/sites/org/pages/new/index.mjs @@ -16,6 +16,7 @@ import { RssIcon, CsetIcon, OpackIcon, + KioskIcon, } from 'shared/components/icons.mjs' // Translation namespaces used on this page @@ -47,6 +48,14 @@ const Box = ({ title, Icon, description, href }) => { {inner} ) } +/* + + */ /* * Each page MUST be wrapped in the PageWrapper component. @@ -76,24 +85,24 @@ const NewIndexPage = ({ page }) => { <>

{t('newShare')}

- - + +