Skip to content

Commit f1d1f8c

Browse files
harlan-zwclaude
andcommitted
feat: add Takumi renderer support
Adds Takumi as an alternative rendering engine for OG images. Takumi is a Rust-based renderer that directly rasterizes to PNG/JPEG/WebP without SVG intermediates, offering 2-10x faster rendering compared to Satori+Resvg. New files: - bindings/takumi/node.ts - Node.js binding - bindings/takumi/wasm.ts - WASM binding for edge runtimes - takumi/instances.ts - Lazy-loaded renderer instance - takumi/nodes.ts - HTML to Takumi node converter - takumi/renderer.ts - Main renderer implementation Features: - Native Tailwind CSS support via `tw` prop - Direct PNG/JPEG/WebP output (no SVG intermediate) - Variable font support - COLR emoji support - RTL text support Configuration: - Set renderer: 'takumi' in defineOgImage() or module defaults - Supports node and wasm bindings based on runtime environment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c9058c4 commit f1d1f8c

10 files changed

Lines changed: 336 additions & 4 deletions

File tree

src/compatibility.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const NodeRuntime: RuntimeCompatibilitySchema = {
3131
'css-inline': 'node',
3232
'resvg': 'node',
3333
'satori': 'node',
34+
'takumi': 'node',
3435
'sharp': 'node', // will be disabled if they're missing the dependency
3536
}
3637

@@ -39,6 +40,7 @@ const cloudflare: RuntimeCompatibilitySchema = {
3940
'css-inline': false,
4041
'resvg': 'wasm',
4142
'satori': 'node',
43+
'takumi': 'wasm',
4244
'sharp': false,
4345
'wasm': {
4446
esmImport: true,
@@ -50,6 +52,7 @@ const awsLambda: RuntimeCompatibilitySchema = {
5052
'css-inline': 'wasm',
5153
'resvg': 'node',
5254
'satori': 'node',
55+
'takumi': 'node',
5356
'sharp': false, // 0.33.x has issues
5457
}
5558

@@ -58,6 +61,7 @@ export const WebContainer: RuntimeCompatibilitySchema = {
5861
'css-inline': 'wasm-fs',
5962
'resvg': 'wasm-fs',
6063
'satori': 'wasm-fs',
64+
'takumi': 'wasm',
6165
'sharp': false,
6266
}
6367

@@ -74,6 +78,7 @@ export const RuntimeCompatibility: Record<string, RuntimeCompatibilitySchema> =
7478
'css-inline': 'wasm',
7579
'resvg': 'wasm',
7680
'satori': 'node',
81+
'takumi': 'wasm',
7782
'sharp': false,
7883
'wasm': {
7984
// @ts-expect-error untyped
@@ -90,6 +95,7 @@ export const RuntimeCompatibility: Record<string, RuntimeCompatibilitySchema> =
9095
'css-inline': false, // size constraint (2mb is max)
9196
'resvg': 'wasm',
9297
'satori': 'node',
98+
'takumi': 'wasm',
9399
'sharp': false,
94100
'wasm': {
95101
// lowers workers kb size
@@ -142,10 +148,12 @@ export async function applyNitroPresetCompatibility(nitroConfig: NitroConfig, op
142148

143149
const satoriEnabled = typeof options.compatibility?.satori !== 'undefined' ? !!options.compatibility.satori : !!compatibility.satori
144150
const chromiumEnabled = typeof options.compatibility?.chromium !== 'undefined' ? !!options.compatibility.chromium : !!compatibility.chromium
151+
const takumiEnabled = typeof options.compatibility?.takumi !== 'undefined' ? !!options.compatibility.takumi : !!compatibility.takumi
145152
// renderers
146153
const emptyMock = await resolve.resolvePath('./runtime/mock/empty')
147154
nitroConfig.alias!['#og-image/renderers/satori'] = satoriEnabled ? await resolve.resolvePath('./runtime/server/og-image/satori/renderer') : emptyMock
148155
nitroConfig.alias!['#og-image/renderers/chromium'] = chromiumEnabled ? await resolve.resolvePath('./runtime/server/og-image/chromium/renderer') : emptyMock
156+
nitroConfig.alias!['#og-image/renderers/takumi'] = takumiEnabled ? await resolve.resolvePath('./runtime/server/og-image/takumi/renderer') : emptyMock
149157

150158
const resolvedCompatibility: Partial<Omit<RuntimeCompatibilitySchema, 'wasm'>> = {}
151159
async function applyBinding(key: keyof Omit<RuntimeCompatibilitySchema, 'wasm'>) {
@@ -166,6 +174,7 @@ export async function applyNitroPresetCompatibility(nitroConfig: NitroConfig, op
166174
nitroConfig.alias = defu(
167175
await applyBinding('chromium'),
168176
await applyBinding('satori'),
177+
await applyBinding('takumi'),
169178
await applyBinding('resvg'),
170179
await applyBinding('sharp'),
171180
await applyBinding('css-inline'),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Renderer } from '@takumi-rs/core'
2+
3+
export default {
4+
initWasmPromise: Promise.resolve(),
5+
Renderer,
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { init, Renderer } from '@takumi-rs/wasm'
2+
3+
const wasmBinary = import('@takumi-rs/wasm/wasm?module' as string)
4+
.then(m => m.default || m)
5+
6+
export default {
7+
initWasmPromise: wasmBinary.then(wasm => init(wasm)),
8+
Renderer,
9+
}

src/runtime/server/og-image/context.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from '../../types'
77
import type ChromiumRenderer from './chromium/renderer'
88
import type SatoriRenderer from './satori/renderer'
9+
import type TakumiRenderer from './takumi/renderer'
910
import { prerenderOptionsCache } from '#og-image-cache'
1011
import { theme } from '#og-image-virtual/unocss-config.mjs'
1112
import { useSiteConfig } from '#site-config/server/composables/useSiteConfig'
@@ -22,7 +23,7 @@ import { decodeOgImageParams, separateProps } from '../../shared'
2223
import { createNitroRouteRuleMatcher } from '../util/kit'
2324
import { normaliseOptions } from '../util/options'
2425
import { useOgImageRuntimeConfig } from '../utils'
25-
import { useChromiumRenderer, useSatoriRenderer } from './instances'
26+
import { useChromiumRenderer, useSatoriRenderer, useTakumiRenderer } from './instances'
2627

2728
export function resolvePathCacheKey(e: H3Event, path: string) {
2829
const siteConfig = useSiteConfig(e, {
@@ -143,14 +144,17 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
143144
}
144145

145146
// TODO merge in component data from component-names, we want the hash to use as a cache key
146-
let renderer: ((typeof SatoriRenderer | typeof ChromiumRenderer) & { __mock__?: true }) | undefined
147+
let renderer: ((typeof SatoriRenderer | typeof ChromiumRenderer | typeof TakumiRenderer) & { __mock__?: true }) | undefined
147148
switch (options.renderer) {
148149
case 'satori':
149150
renderer = await useSatoriRenderer()
150151
break
151152
case 'chromium':
152153
renderer = await useChromiumRenderer()
153154
break
155+
case 'takumi':
156+
renderer = await useTakumiRenderer()
157+
break
154158
}
155159
if (!renderer || renderer.__mock__) {
156160
throw createError({

src/runtime/server/og-image/instances.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type ChromiumRenderer from './chromium/renderer'
22
import type SatoriRenderer from './satori/renderer'
3+
import type TakumiRenderer from './takumi/renderer'
34

45
// we keep instances alive to avoid re-importing them on every request, maybe not needed but
56
// also helps with type inference
67
const satoriRendererInstance: { instance?: typeof SatoriRenderer } = { instance: undefined }
78
const chromiumRendererInstance: { instance?: typeof ChromiumRenderer } = { instance: undefined }
9+
const takumiRendererInstance: { instance?: typeof TakumiRenderer } = { instance: undefined }
810

911
export async function useSatoriRenderer() {
1012
satoriRendererInstance.instance = satoriRendererInstance.instance || await import('#og-image/renderers/satori').then(m => m.default)
@@ -15,3 +17,8 @@ export async function useChromiumRenderer() {
1517
chromiumRendererInstance.instance = chromiumRendererInstance.instance || await import('#og-image/renderers/chromium').then(m => m.default)
1618
return chromiumRendererInstance.instance!
1719
}
20+
21+
export async function useTakumiRenderer() {
22+
takumiRendererInstance.instance = takumiRendererInstance.instance || await import('#og-image/renderers/takumi').then(m => m.default)
23+
return takumiRendererInstance.instance!
24+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Renderer } from '@takumi-rs/core'
2+
3+
const takumiInstance: { instance?: { initWasmPromise: Promise<void>, Renderer: typeof Renderer } } = { instance: undefined }
4+
5+
export async function useTakumi() {
6+
takumiInstance.instance = takumiInstance.instance || await import('#og-image/bindings/takumi').then(m => m.default)
7+
await takumiInstance.instance!.initWasmPromise
8+
return takumiInstance.instance!.Renderer
9+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { OgImageRenderEventContext } from '../../../types'
2+
import { parseHTML } from 'linkedom'
3+
import { htmlDecodeQuotes } from '../../util/encoding'
4+
import { fetchIsland } from '../../util/kit'
5+
import { applyEmojis } from '../satori/transforms/emojis'
6+
import { applyInlineCss } from '../satori/transforms/inlineCss'
7+
8+
export interface TakumiNode {
9+
children?: TakumiNode[]
10+
text?: string
11+
src?: string
12+
width?: number
13+
height?: number
14+
style?: Record<string, any>
15+
tw?: string
16+
}
17+
18+
export async function createTakumiNodes(ctx: OgImageRenderEventContext): Promise<TakumiNode> {
19+
let html = ctx.options.html
20+
if (!html) {
21+
const island = await fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props : ctx.options)
22+
island.html = htmlDecodeQuotes(island.html)
23+
await applyInlineCss(ctx, island)
24+
await applyEmojis(ctx, island)
25+
html = island.html
26+
if (html?.includes('<body>'))
27+
html = html.match(/<body>([\s\S]*)<\/body>/)?.[1] || ''
28+
}
29+
30+
const template = `<div style="position: relative; display: flex; margin: 0 auto; width: ${ctx.options.width}px; height: ${ctx.options.height}px; overflow: hidden;">${html}</div>`
31+
const { document } = parseHTML(template)
32+
const root = document.body.firstElementChild || document.body
33+
34+
return elementToNode(root as Element, ctx)
35+
}
36+
37+
function elementToNode(el: Element, ctx: OgImageRenderEventContext): TakumiNode {
38+
const tagName = el.tagName.toLowerCase()
39+
40+
// Handle images
41+
if (tagName === 'img') {
42+
return {
43+
src: resolveImageSrc(el.getAttribute('src') || '', ctx),
44+
width: Number(el.getAttribute('width')) || undefined,
45+
height: Number(el.getAttribute('height')) || undefined,
46+
tw: el.getAttribute('class') || undefined,
47+
style: parseStyleAttr(el.getAttribute('style')),
48+
}
49+
}
50+
51+
// Handle SVG - convert to data URI
52+
if (tagName === 'svg') {
53+
const svgString = el.outerHTML
54+
const dataUri = `data:image/svg+xml;base64,${Buffer.from(svgString).toString('base64')}`
55+
return {
56+
src: dataUri,
57+
width: Number(el.getAttribute('width')) || undefined,
58+
height: Number(el.getAttribute('height')) || undefined,
59+
}
60+
}
61+
62+
// Handle text-only elements
63+
if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
64+
return {
65+
text: el.textContent || '',
66+
tw: el.getAttribute('class') || undefined,
67+
style: parseStyleAttr(el.getAttribute('style')),
68+
}
69+
}
70+
71+
// Handle containers
72+
const children: TakumiNode[] = []
73+
for (const child of el.childNodes) {
74+
if (child.nodeType === 1)
75+
children.push(elementToNode(child as Element, ctx))
76+
else if (child.nodeType === 3 && child.textContent?.trim())
77+
children.push({ text: child.textContent.trim() })
78+
}
79+
80+
return {
81+
children: children.length ? children : undefined,
82+
tw: el.getAttribute('class') || undefined,
83+
style: parseStyleAttr(el.getAttribute('style')),
84+
}
85+
}
86+
87+
function resolveImageSrc(src: string, _ctx: OgImageRenderEventContext): string {
88+
// Already a data URI or absolute URL
89+
if (src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://'))
90+
return src
91+
// Relative path - return as-is and let Takumi handle it
92+
return src
93+
}
94+
95+
function parseStyleAttr(style: string | null): Record<string, any> | undefined {
96+
if (!style)
97+
return undefined
98+
const result: Record<string, any> = {}
99+
for (const decl of style.split(';')) {
100+
const [prop, ...valParts] = decl.split(':')
101+
const val = valParts.join(':').trim()
102+
if (prop?.trim() && val)
103+
result[camelCase(prop.trim())] = val
104+
}
105+
return Object.keys(result).length ? result : undefined
106+
}
107+
108+
function camelCase(str: string): string {
109+
return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
110+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { Renderer as TakumiRendererType } from '@takumi-rs/core'
2+
import type { OgImageRenderEventContext, Renderer, ResolvedFontConfig } from '../../../types'
3+
import { fontCache } from '#og-image-cache'
4+
import { defu } from 'defu'
5+
import { sendError } from 'h3'
6+
import { normaliseFontInput } from '../../../shared'
7+
import { useOgImageRuntimeConfig } from '../../utils'
8+
import { loadFont } from '../satori/font'
9+
import { useTakumi } from './instances'
10+
import { createTakumiNodes } from './nodes'
11+
12+
const fontPromises: Record<string, Promise<ResolvedFontConfig>> = {}
13+
14+
async function resolveFonts(event: OgImageRenderEventContext) {
15+
const { fonts } = useOgImageRuntimeConfig()
16+
const normalisedFonts = normaliseFontInput([...event.options.fonts || [], ...fonts])
17+
const localFontPromises: Promise<ResolvedFontConfig>[] = []
18+
const preloadedFonts: ResolvedFontConfig[] = []
19+
20+
if (fontCache) {
21+
for (const font of normalisedFonts) {
22+
if (await fontCache.hasItem(font.cacheKey)) {
23+
font.data = (await fontCache.getItemRaw(font.cacheKey)) || undefined
24+
preloadedFonts.push(font)
25+
}
26+
else {
27+
if (!fontPromises[font.cacheKey]) {
28+
fontPromises[font.cacheKey] = loadFont(event, font).then(async (_font) => {
29+
if (_font?.data)
30+
await fontCache?.setItemRaw(_font.cacheKey, _font.data)
31+
return _font
32+
})
33+
}
34+
localFontPromises.push(fontPromises[font.cacheKey]!)
35+
}
36+
}
37+
}
38+
const awaitedFonts = await Promise.all(localFontPromises)
39+
return [...preloadedFonts, ...awaitedFonts].map(_f => ({
40+
name: _f.name,
41+
data: _f.data,
42+
}))
43+
}
44+
45+
let _takumiRenderer: InstanceType<typeof TakumiRendererType> | undefined
46+
47+
async function getTakumiRenderer(fonts: Array<{ name: string, data?: BufferSource }>) {
48+
if (_takumiRenderer)
49+
return _takumiRenderer
50+
const Renderer = await useTakumi()
51+
_takumiRenderer = new Renderer({ fonts: fonts.filter(f => f.data) })
52+
return _takumiRenderer
53+
}
54+
55+
async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jpeg' | 'webp') {
56+
const { options } = event
57+
58+
const [nodes, fonts] = await Promise.all([
59+
createTakumiNodes(event),
60+
resolveFonts(event),
61+
])
62+
63+
await event._nitro.hooks.callHook('nuxt-og-image:takumi:nodes', nodes, event)
64+
65+
const renderer = await getTakumiRenderer(fonts)
66+
67+
return renderer.render(nodes, defu(options.takumi, {
68+
width: options.width!,
69+
height: options.height!,
70+
format,
71+
})).catch((err: Error) => sendError(event.e, err, import.meta.dev))
72+
}
73+
74+
const TakumiRenderer: Renderer = {
75+
name: 'takumi',
76+
supportedFormats: ['png', 'jpeg', 'jpg', 'webp'],
77+
78+
async createImage(e) {
79+
switch (e.extension) {
80+
case 'png':
81+
return createImage(e, 'png')
82+
case 'jpeg':
83+
case 'jpg':
84+
return createImage(e, 'jpeg')
85+
}
86+
},
87+
88+
async debug(e) {
89+
const nodes = await createTakumiNodes(e)
90+
return { nodes }
91+
},
92+
}
93+
94+
export default TakumiRenderer

src/runtime/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export interface OgImageOptions<T extends keyof OgImageComponents = 'NuxtSeo'> {
118118
* Props to pass to the component.
119119
*/
120120
props?: OgImageComponents[T] | Record<string, any>
121-
renderer?: 'chromium' | 'satori'
121+
renderer?: 'chromium' | 'satori' | 'takumi'
122122
extension?: 'png' | 'jpeg' | 'jpg'
123123
emojis?: IconifyEmojiIconSets
124124
/**
@@ -130,6 +130,10 @@ export interface OgImageOptions<T extends keyof OgImageComponents = 'NuxtSeo'> {
130130
satori?: SatoriOptions
131131
screenshot?: Partial<ScreenshotOptions>
132132
sharp?: SharpOptions & JpegOptions
133+
takumi?: {
134+
format?: 'png' | 'jpeg' | 'webp'
135+
persistentImages?: Array<{ src: string, data: ArrayBuffer }>
136+
}
133137
fonts?: InputFontConfig[]
134138
// cache
135139
cacheMaxAgeSeconds?: number
@@ -170,6 +174,7 @@ export interface RuntimeCompatibilitySchema {
170174
['css-inline']: 'node' | 'wasm' | 'wasm-fs' | false
171175
resvg: 'node' | 'wasm' | 'wasm-fs' | false
172176
satori: 'node' | 'wasm' | 'wasm-fs' | false
177+
takumi: 'node' | 'wasm' | false
173178
sharp: 'node' | false
174179
wasm?: NitroOptions['wasm']
175180
}
@@ -185,7 +190,7 @@ export interface CompatibilityFlagEnvOverrides {
185190
export type RendererOptions = Omit<OgImageOptions, 'extension'> & { extension: Omit<OgImageOptions['extension'], 'html'> }
186191

187192
export interface Renderer {
188-
name: 'chromium' | 'satori'
193+
name: 'chromium' | 'satori' | 'takumi'
189194
supportedFormats: Partial<RendererOptions['extension']>[]
190195
createImage: (e: OgImageRenderEventContext) => Promise<H3Error | BufferSource | Buffer | Uint8Array | void | undefined>
191196
debug: (e: OgImageRenderEventContext) => Promise<Record<string, any>>

0 commit comments

Comments
 (0)