Skip to content

Commit 1ccfad5

Browse files
committed
improve
1 parent 0bc366b commit 1ccfad5

2 files changed

Lines changed: 43 additions & 13 deletions

File tree

src/video/index.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ export type VideoOptions = Omit<SatoriOptions, 'width' | 'height'> & {
1212
bitrate?: number
1313
quality?: number
1414
groupOfPictures?: number
15+
/**
16+
* Number of frames whose Satori + sharp work can be in flight at once. The
17+
* H.264 encoder still consumes frames strictly in order — concurrency lets
18+
* upcoming frames render while the current one is being encoded, and lets
19+
* sharp use its libuv threadpool for multiple frames in parallel.
20+
*
21+
* Default: 4 (matches the default libuv UV_THREADPOOL_SIZE).
22+
*/
23+
concurrency?: number
1524
}
1625

1726
export type FrameContext = {
@@ -79,6 +88,7 @@ export async function video(
7988
bitrate,
8089
quality,
8190
groupOfPictures,
91+
concurrency = 4,
8292
...rest
8393
} = options
8494

@@ -93,9 +103,13 @@ export async function video(
93103
if (fps <= 0) {
94104
throw new Error('satori/video: fps must be > 0')
95105
}
106+
if (!Number.isInteger(concurrency) || concurrency < 1) {
107+
throw new Error('satori/video: concurrency must be a positive integer')
108+
}
96109

97110
const satoriOptions = { ...rest, width, height } as SatoriOptions
98111
const totalFrames = computeTotalFrames(duration, fps)
112+
const windowSize = Math.min(concurrency, totalFrames)
99113

100114
const encoder = await createH264MP4Encoder()
101115
encoder.width = width
@@ -109,17 +123,33 @@ export async function video(
109123
.slice(2)}.mp4`
110124
encoder.initialize()
111125

126+
// Sliding window of in-flight render promises. Producers run ahead of the
127+
// encoder so Satori + sharp can overlap with the synchronous WASM encode.
128+
const inFlight = new Map<number, Promise<Buffer>>()
129+
const startFrame = (i: number) => {
130+
const p = renderFrame(
131+
renderer,
132+
satoriOptions,
133+
width,
134+
height,
135+
i,
136+
totalFrames,
137+
fps
138+
)
139+
// Suppress unhandled-rejection warnings for frames we may never await
140+
// (e.g. an earlier frame throws and we bail out of the loop).
141+
p.catch(() => undefined)
142+
inFlight.set(i, p)
143+
}
144+
145+
let nextStart = 0
146+
while (nextStart < windowSize) startFrame(nextStart++)
147+
112148
try {
113-
for (let frame = 0; frame < totalFrames; frame++) {
114-
const rgba = await renderFrame(
115-
renderer,
116-
satoriOptions,
117-
width,
118-
height,
119-
frame,
120-
totalFrames,
121-
fps
122-
)
149+
for (let i = 0; i < totalFrames; i++) {
150+
const rgba = await inFlight.get(i)!
151+
inFlight.delete(i)
152+
if (nextStart < totalFrames) startFrame(nextStart++)
123153
encoder.addFrameRgba(rgba)
124154
}
125155
encoder.finalize()

test/video.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('Video', () => {
3232
]
3333
})
3434

35-
it.only('renders a kinetic title card to MP4', async () => {
35+
it('renders a kinetic title card to MP4', async () => {
3636
const mp4 = await video(
3737
({ progress }) => {
3838
const dot = easeOutQuint(range(progress, 0, 0.4))
@@ -105,11 +105,10 @@ describe('Video', () => {
105105
fontSize: 18,
106106
fontWeight: 400,
107107
opacity: tagline * 0.72,
108-
letterSpacing: 8,
109108
transform: `translateY(${(1 - tagline) * 16}px)`,
110109
}}
111110
>
112-
ENLIGHTENED JSX, NOW IN MOTION
111+
enlightened jsx, now in motion
113112
</div>
114113
</div>
115114
)
@@ -121,6 +120,7 @@ describe('Video', () => {
121120
fps: FPS,
122121
fonts,
123122
quality: 22,
123+
concurrency: 4,
124124
}
125125
)
126126

0 commit comments

Comments
 (0)