Created
February 19, 2025 13:23
-
-
Save pketh/5942d2d50f9269cfcdde1ebc8e3d62bd to your computer and use it in GitHub Desktop.
Generates a bitmapped preview image of a space document
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import controllers from './controllers.js' | |
import utils from '../utils.js' | |
import consts from '../consts.js' | |
import { createCanvas, loadImage, createImageData, CanvasRenderingContext2D, DOMMatrix, ImageData } from 'canvas' | |
import { polyfillPath2D } from 'path2d-polyfill' | |
import webp from '@cwasm/webp' | |
import moment from 'moment' | |
import _ from 'lodash' | |
import heicConvert from 'heic-convert' | |
global.CanvasRenderingContext2D = CanvasRenderingContext2D | |
polyfillPath2D(global) // adds Path2D support | |
let canvas, context | |
const width = 1180 | |
const height = 670 | |
let intervalMinutes = 10 | |
if (consts.isDevelopment) { | |
intervalMinutes = 0 | |
} | |
const intervalTime = intervalMinutes * 60 * 1000 | |
let throttleCache = [] | |
let theme | |
export default { | |
async create (space, themeOptions) { | |
// console.log('๐ spacePreviewImage.create', space.id, themeOptions) | |
checkThrottleCache(space.id) | |
try { | |
const time = process.hrtime() | |
theme = themeOptions.theme | |
const lockedCards = space.cards.filter(card => card.isLocked) | |
const unlockedCards = space.cards.filter(card => !card.isLocked) | |
initCanvas() | |
await drawBackground(space) | |
await drawBackgroundGradient(space) | |
await drawBackgroundTint(space) | |
await drawBoxes(space) | |
await drawConnections(space) | |
await drawCards(space, lockedCards) | |
await drawCards(space, unlockedCards) | |
await drawItemConnectors(space) | |
const image = resizeCanvas(1) // ~34kb | |
const imageThumbnail = resizeCanvas(6) // ~2kb | |
// console.log(`๐ created space preview images`, utils.elapsedTime(time)) | |
const url = await upload({ space, newCanvas: image, name: 'preview-image' }) | |
const thumbnailUrl = await upload({ space, newCanvas: imageThumbnail, name: 'preview-image-thumbnail' }) | |
const updates = { | |
previewImage: url, | |
previewThumbnailImage: thumbnailUrl | |
} | |
await controllers.space.update(space, updates) | |
updateThrottleCache(space.id) | |
let canvas, context = null // prevents memory leaks? | |
// console.log('๐ space preview image urls', updates) | |
return updates | |
} catch (error) { | |
throw { status: 500, message: `๐ space preview image create error`, error } | |
} | |
}, | |
} | |
const loadImageFromUrl = async (imageUrl) => { | |
const isWebp = imageUrl.includes('.webp') | |
const isHeic = imageUrl.includes('.heic') | |
if (isWebp) { | |
const response = await fetch(imageUrl) | |
const buffer = Buffer.from(await response.arrayBuffer()) | |
const { data, width, height } = webp.decode(buffer) | |
const imageCanvas = createCanvas(width, height) | |
const imageContext = imageCanvas.getContext('2d') | |
const imageData = new ImageData(data, width, height) | |
imageContext.putImageData(imageData, 0, 0) | |
const dataUrl = imageCanvas.toDataURL() | |
return loadImage(dataUrl) | |
} else if (isHeic) { | |
const response = await fetch(imageUrl) | |
let buffer = Buffer.from(await response.arrayBuffer()) | |
buffer = await heicConvert({ | |
buffer, | |
format: 'JPEG' | |
}) | |
const base64Image = buffer.toString('base64') | |
const mimeType = 'image/jpeg' | |
const dataUrl = `data:${mimeType};base64,${base64Image}` | |
return loadImage(dataUrl) | |
} else { | |
return loadImage(imageUrl) | |
} | |
} | |
// throttle cache | |
const checkThrottleCache = (spaceId) => { | |
const isCached = throttleCache.find(item => item.spaceId === spaceId) | |
if (isCached) { | |
// https://httpstatusdogs.com/429-too-many-requests | |
throw { status: 429, message: `๐ space preview image recently created, try again in ${intervalMinutes} minutes`, spaceId } | |
} | |
} | |
const updateThrottleCache = (spaceId) => { | |
throttleCache.push({ spaceId, time: new Date() }) | |
} | |
setInterval(() => { | |
throttleCache = throttleCache.filter(item => { | |
const time = moment(item.time) | |
const now = moment() | |
const minutes = now.diff(time, 'minutes') | |
return minutes < intervalMinutes | |
}) | |
}, intervalTime) | |
// canvas | |
const initCanvas = () => { | |
canvas = createCanvas(width, height) | |
context = canvas.getContext('2d') | |
context.fillStyle = theme.primaryBackground | |
context.fillRect(0, 0, width, height) | |
} | |
const resizeCanvas = (resizeBy) => { | |
const newWidth = Math.round(width / resizeBy) | |
const newHeight = Math.round(height / resizeBy) | |
const newCanvas = createCanvas(newWidth, newHeight) | |
const newContext = newCanvas.getContext('2d') | |
newContext.imageSmoothingEnabled = false | |
newContext.drawImage(context.canvas, 0, 0, newWidth, newHeight) | |
return newCanvas | |
} | |
const upload = async ({ space, newCanvas, name }) => { | |
const key = `${space.id}/${name}-${space.id}.jpeg` | |
const buffer = await newCanvas.toBuffer('image/jpeg') | |
const cacheShouldExpire = true | |
const url = await controllers.upload.uploadBuffer({ buffer, key, cacheShouldExpire }) | |
return url | |
} | |
// background | |
const drawBackground = async (space) => { | |
if (space.backgroundIsGradient) { return } | |
const backgroundUrl = theme.background || space.background || consts.defaultBackground | |
const isRetina = backgroundUrl.includes('-2x.') || backgroundUrl.includes('@2x.') | |
try { | |
const image = await loadImageFromUrl(backgroundUrl) | |
const pattern = context.createPattern(image, 'repeat') | |
if (isRetina) { | |
const matrix = new DOMMatrix() | |
pattern.setTransform(matrix.scale(0.5)) | |
} | |
context.fillStyle = pattern | |
context.fillRect(0, 0, width, height) | |
} catch (error) { | |
console.error('๐ drawBackground', error) | |
} | |
} | |
const drawBackgroundGradient = async (space) => { | |
if (!space.backgroundIsGradient) { return } | |
let gradients = space.backgroundGradient | |
const pageSize = utils.pageSizeFromItems(space.cards, space.boxes) | |
const numberOfGradientLayers = 6 | |
gradients.reverse() | |
// solid layer | |
const layer = gradients[0] | |
context.fillStyle = layer.color | |
context.fillRect(0, 0, width, height) | |
gradients.shift() | |
gradients.reverse() | |
// gradient layers | |
_.times(numberOfGradientLayers, (index) => { | |
const skipIndexes = [0, 2, 4] | |
if (skipIndexes.includes(index)) { return } // skip opacity 0 layers | |
const layer = gradients[index] | |
const x = (layer.x / 100) * pageSize.width | |
const y = (layer.y / 100) * pageSize.height | |
let gradient = context.createRadialGradient(x, y, 0, x, y, pageSize.width) // x0, y0, r0, x1, y1, r1 | |
gradient.addColorStop(0, layer.color1) | |
gradient.addColorStop(0.8, layer.color2) | |
context.fillStyle = gradient | |
context.fillRect(0, 0, width, height) | |
}) | |
} | |
const drawBackgroundTint = async (space) => { | |
const color = theme.backgroundTint || space.backgroundTint | |
if (!color) { return } | |
let tint = new Path2D() | |
tint.rect(0, 0, width, height) | |
context.fillStyle = color | |
context.globalCompositeOperation = 'multiply' | |
context.fill(tint) | |
context.globalCompositeOperation = 'source-over' // default blend mode | |
} | |
// boxes | |
const drawBoxes = async (space) => { | |
for (const box of space.boxes) { | |
if (box.y > height) { continue } | |
let rect = new Path2D() | |
rect.roundRect(box.x, box.y, box.resizeWidth, box.resizeHeight, theme.entityRadius) | |
context.strokeStyle = box.color | |
context.lineWidth = 2 | |
context.stroke(rect) | |
if (box.fill === 'filled') { | |
context.fillStyle = box.color | |
context.globalAlpha = 0.5 | |
context.fill(rect) | |
context.globalAlpha = 1 // default alpha | |
} | |
await drawBoxInfo(box) | |
} | |
} | |
const drawBoxInfo = async (box) => { | |
const entityRadius = theme.entityRadius | |
const radii = [entityRadius, 0, entityRadius, 0] // top-left, top-right, bottom-right, bottom-left | |
let rect = new Path2D() | |
rect.roundRect(box.x, box.y, box.infoWidth, box.infoHeight, radii) | |
context.fillStyle = box.color | |
context.fill(rect) | |
} | |
// cards | |
const removeEmptyCards = (cards) => { | |
cards = cards.filter(card => { | |
return card.x && card.y && card.name && !card.isRemoved | |
}) | |
return cards | |
} | |
const drawCards = async (space, cards) => { | |
cards = removeEmptyCards(cards) | |
for (const card of cards) { | |
// if (card.y > height) { continue } | |
let rect = new Path2D() | |
rect.roundRect(card.x, card.y, card.width, card.height, theme.entityRadius) | |
context.fillStyle = card.backgroundColor || theme.secondaryBackground | |
context.fill(rect) | |
await drawCardImage(card) | |
} | |
} | |
const cardImageUrl = (card) => { | |
// image url in card name | |
const urls = utils.urlsFromString(card.name) | |
if (!urls) { return } | |
const imageUrl = urls.find(url => utils.urlIsImage(url)) | |
if (imageUrl) { | |
return imageUrl | |
} | |
// url preview image | |
const imageUrlIsUrlPreview = card.urlPreviewImage && card.urlPreviewIsVisible && !card.shouldHideUrlPreviewImage | |
if (imageUrlIsUrlPreview) { | |
return card.urlPreviewImage | |
} | |
} | |
const drawCardImage = async (card) => { | |
let imageUrl = cardImageUrl(card) | |
if (!imageUrl) { return } | |
try { | |
const image = await loadImageFromUrl(imageUrl) | |
context.save() | |
context.roundRect(card.x, card.y, card.width, card.height, theme.entityRadius) | |
context.clip() | |
context.drawImage(image, card.x, card.y, card.width, card.height) | |
context.restore() | |
} catch (error) { | |
console.error('๐ drawCardImage', error, card) | |
} | |
} | |
// connections | |
const drawConnections = async (space) => { | |
for (const connection of space.connections) { | |
context.lineWidth = 5 | |
context.lineCap = 'round' | |
const type = space.connectionTypes.find(connectionType => connectionType.id === connection.connectionTypeId) | |
if (!type) { continue } | |
context.strokeStyle = type.color | |
const path = new Path2D(connection.path) | |
context.stroke(path) | |
} | |
} | |
// card connectors | |
const itemById = (space, itemId) => { | |
const card = space.cards.find(card => card.id === itemId) | |
const box = space.boxes.find(box => box.id === itemId) | |
return card || box | |
} | |
const drawItemConnectors = async (space) => { | |
for (const connection of space.connections) { | |
const type = space.connectionTypes.find(connectionType => connectionType.id === connection.connectionTypeId) | |
if (!type) { continue } | |
const startItem = itemById(space, connection.startItemId) | |
const endItem = itemById(space, connection.endItemId) | |
await drawCardConnector(type, startItem) | |
await drawCardConnector(type, endItem) | |
} | |
} | |
const drawCardConnector = async (connectionType, item) => { | |
if (!item) { return } | |
let typeColor = connectionType.color || 'transparent' | |
const radius = 7 | |
const margin = 16 | |
const width = item.resizeWidth || item.width | |
const x = item.x + width - margin | |
const y = item.y + margin | |
let circle = new Path2D() | |
circle.arc(x, y, radius, 0, 2 * Math.PI) | |
context.strokeStyle = theme.primaryBorder | |
context.lineWidth = 1 | |
context.stroke(circle) | |
context.fillStyle = typeColor | |
context.fill(circle) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment