Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ node_modules
dist
.pnpm-debug.log
__diff_output__
__video_output__
.eslintcache
coverage

Expand Down
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"*": {
"wasm": [
"./dist/index.d.ts"
],
"video": [
"./dist/video/index.d.ts"
]
}
},
Expand Down Expand Up @@ -53,6 +56,11 @@
"types": "./dist/jsx/jsx-runtime.d.ts",
"import": "./dist/jsx/jsx-runtime.js",
"require": "./dist/jsx/jsx-runtime.cjs"
},
"./video": {
"types": "./dist/video/index.d.ts",
"import": "./dist/video/index.js",
"require": "./dist/video/index.cjs"
}
},
"scripts": {
Expand Down Expand Up @@ -123,6 +131,14 @@
"typescript": "^5",
"vitest": "^0.32.0"
},
"peerDependencies": {
"sharp": "*"
},
"peerDependenciesMeta": {
"sharp": {
"optional": true
}
},
"dependencies": {
"@shuding/opentype.js": "1.4.0-beta.0",
"css-background-parser": "^0.1.0",
Expand All @@ -131,6 +147,7 @@
"css-to-react-native": "^3.0.0",
"emoji-regex-xs": "^2.0.1",
"escape-html": "^1.0.3",
"h264-mp4-encoder": "^1.0.12",
"linebreak": "^1.1.0",
"parse-css-color": "^0.2.1",
"postcss-value-parser": "^4.2.0",
Expand Down
3 changes: 2 additions & 1 deletion playground/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/dev/types/routes.d.ts'

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
7 changes: 4 additions & 3 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
"fflate": "^0.7.3",
"intl-segmenter-polyfill": "^0.4.4",
"js-base64": "^3.7.2",
"next": "^12.2.5",
"next": "^16.2.6",
"pdfkit": "^0.13.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "0.0.0-experimental-d5736f09-20260507",
"react-dom": "0.0.0-experimental-d5736f09-20260507",
"react-hot-toast": "^2.3.0",
"react-live": "^2.4.1",
"react-resizable-panels": "^0.0.30",
"satori": "workspace:*",
"sharp": "^0.34.3",
"svg-to-pdfkit": "^0.1.8"
},
"devDependencies": {
Expand Down
306 changes: 306 additions & 0 deletions playground/pages/api/video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { NextApiRequest, NextApiResponse } from 'next'
import React from 'react'
import satori from 'satori'
import { video } from 'satori/video'

// Record module-evaluation timing so a stage probe can report cold-start cost.
const MODULE_LOADED_AT = Date.now()

const TAGLINE = 'ENLIGHTENED JSX, NOW IN MOTION'
const W = 960
const H = 540
const FPS = 30
const DURATION_MS = 3000

const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)
const range = (t: number, a: number, b: number) => clamp01((t - a) / (b - a))
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3)
const easeOutQuint = (t: number) => 1 - Math.pow(1 - t, 5)

type LoadedFonts = Array<{
name: string
data: Buffer
weight: 400 | 700
style: 'normal'
}>

let fontsPromise: Promise<LoadedFonts> | null = null
function loadFonts(): Promise<LoadedFonts> {
if (fontsPromise) return fontsPromise
const promise: Promise<LoadedFonts> = (async () => {
const [regular, bold] = await Promise.all([
readFile(join(process.cwd(), 'public/inter-latin-ext-400-normal.woff')),
readFile(join(process.cwd(), 'public/inter-latin-ext-700-normal.woff')),
])
return [
{ name: 'Inter', data: regular, weight: 400, style: 'normal' },
{ name: 'Inter', data: bold, weight: 700, style: 'normal' },
]
})()
promise.catch(() => {
if (fontsPromise === promise) fontsPromise = null
})
fontsPromise = promise
return promise
}

function sanitizeText(raw: unknown): string {
if (typeof raw !== 'string') return 'satori'
// Strip control chars, collapse, clip to 10 grapheme-ish chars.
// (Codepoint-clip is good enough for a demo.)
const cleaned = Array.from(raw)
.filter((c) => c >= ' ' && c !== '\x7f')
.slice(0, 10)
.join('')
.trim()
return cleaned.length ? cleaned : 'satori'
}

function renderTitleCard(text: string, progress: number): React.ReactElement {
const dot = easeOutQuint(range(progress, 0, 0.4))
const tagline = easeOutCubic(range(progress, 0.7, 1))
const pulse = 1 + 0.18 * Math.sin(range(progress, 0.8, 1) * Math.PI)

const hueShift = progress * 30
const bgInner = `hsl(${250 + hueShift}, 45%, 16%)`
const bgOuter = `hsl(${230 + hueShift}, 40%, 6%)`

// Scale font size down as text grows so 10 chars still fit.
const fontSize = Math.max(72, Math.min(168, 720 / Math.max(text.length, 4)))
const stagger = Math.min(0.08, 0.45 / Math.max(text.length, 1))

return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: `radial-gradient(circle at 50% 42%, ${bgInner}, ${bgOuter})`,
color: 'white',
fontFamily: 'Inter',
}}
>
<div
style={{
display: 'flex',
width: 18,
height: 18,
borderRadius: 999,
backgroundColor: 'white',
opacity: dot,
transform: `scale(${(0.25 + 0.75 * dot) * pulse})`,
marginBottom: 40,
boxShadow: `0 0 ${24 + 56 * dot}px rgba(255,255,255,${0.55 * dot})`,
}}
/>

<div style={{ display: 'flex', padding: '12px 0' }}>
{Array.from(text).map((ch, i) => {
const start = 0.22 + i * stagger
const t = easeOutCubic(range(progress, start, start + 0.5))
const baseStyle = {
display: 'flex',
fontSize,
fontWeight: 700,
letterSpacing: -fontSize * 0.04,
opacity: t,
transform: `translateY(${(1 - t) * 70}px)`,
padding: '0 2px',
lineHeight: 1.1,
} as const
if (ch === ' ') {
return (
<div key={i} style={{ ...baseStyle, width: fontSize * 0.35 }} />
)
}
return (
<div key={i} style={baseStyle}>
{ch}
</div>
)
})}
</div>

<div
style={{
display: 'flex',
marginTop: 28,
fontSize: 18,
fontWeight: 400,
opacity: tagline * 0.72,
letterSpacing: 8,
transform: `translateY(${(1 - tagline) * 16}px)`,
}}
>
{TAGLINE}
</div>
</div>
)
}

export const config = {
api: {
// Encoded MP4 may be a few MB; leave room.
responseLimit: '16mb',
},
}

const SOLID_FRAME = {
type: 'div',
props: {
style: {
width: '100%',
height: '100%',
backgroundColor: 'red',
},
},
} as const

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const stage = String(req.query.stage ?? 'full')
const handlerStart = Date.now()
const log = (event: string, extra: Record<string, unknown> = {}) => {
console.log(
`[video] stage=${stage} ${event} ms=${Date.now() - handlerStart}`,
extra
)
}

try {
// Stage 0: did module evaluation complete? If you hit a 120s timeout
// *before* this handler runs, none of the stage probes will respond — the
// hang is in one of the top-level imports (satori/video, sharp, h264).
if (stage === 'module') {
return res.status(200).json({
ok: true,
moduleLoadAt: MODULE_LOADED_AT,
handlerStart,
sinceModuleLoad: handlerStart - MODULE_LOADED_AT,
})
}

// Stage 1: just import the video entry. With static imports it's already
// done; this probe confirms the module is reachable.
if (stage === 'import') {
log('import-only')
return res.status(200).json({
ok: true,
videoFn: typeof video,
satoriFn: typeof satori,
ms: Date.now() - handlerStart,
})
}

// Stage 2: read fonts from disk. Will fail with ENOENT if `public/` isn't
// bundled into the serverless function.
if (stage === 'fonts') {
const fonts = await loadFonts()
log('fonts-loaded', { count: fonts.length })
return res.status(200).json({
ok: true,
fonts: fonts.map((f) => ({
name: f.name,
weight: f.weight,
bytes: f.data.byteLength,
})),
ms: Date.now() - handlerStart,
})
}

// Stage 3: one Satori call → SVG string. Exercises yoga + text shaping,
// but neither sharp nor the encoder.
if (stage === 'satori') {
const fonts = await loadFonts()
log('fonts-loaded')
const svg = await satori(renderTitleCard('hi', 0.5), {
width: 320,
height: 180,
fonts: fonts as any,
})
log('satori-done', { svgBytes: svg.length })
return res.status(200).json({
ok: true,
svgBytes: svg.length,
ms: Date.now() - handlerStart,
})
}

// Stage 4: full video pipeline but trivial — 1 frame, 64×64, solid color
// (no text shaping). If everything else passed and this hangs, the
// h264-mp4-encoder WASM is the suspect.
if (stage === 'encode-one') {
const fonts = await loadFonts()
log('fonts-loaded')
const mp4 = await video(() => SOLID_FRAME as any, {
width: 64,
height: 64,
duration: 33,
fps: 30,
fonts: fonts as any,
})
log('encode-done', { bytes: mp4.byteLength })
return res.status(200).json({
ok: true,
bytes: mp4.byteLength,
ms: Date.now() - handlerStart,
})
}

// Stage full (default): the real thing, with per-frame timing.
const text = sanitizeText(req.query.text)
const fonts = await loadFonts()
log('fonts-loaded')

let frameCount = 0
const sampleTimings: Array<{ frame: number; ms: number }> = []
let lastFrameEnd = Date.now()

const mp4 = await video(
({ progress, frame }: { progress: number; frame: number }) => {
if (frame % 10 === 0) {
const now = Date.now()
sampleTimings.push({ frame, ms: now - lastFrameEnd })
lastFrameEnd = now
}
frameCount++
return renderTitleCard(text, progress)
},
{
width: W,
height: H,
duration: DURATION_MS,
fps: FPS,
fonts: fonts as any,
quality: 22,
}
)

log('full-done', {
frameCount,
bytes: mp4.byteLength,
sampleTimings,
})

res.setHeader('Content-Type', 'video/mp4')
res.setHeader('Content-Length', String(mp4.byteLength))
res.setHeader(
'Cache-Control',
'public, max-age=60, s-maxage=300, stale-while-revalidate=600'
)
res.status(200).send(Buffer.from(mp4))
} catch (err) {
console.error('[/api/video] failed at stage=', stage, err)
res.status(500).json({
stage,
error: (err as Error).message ?? 'video render failed',
})
}
}
Loading
Loading