102 | return (
103 |
104 |
117 | {devMode && {currentFrame} ID{renderId}
}
118 |
119 |
120 |
121 |
122 | );
123 | };
124 |
125 | async function setupReact({ devMode, width, height, fps, serverPort, durationFrames, renderId, userData, videoComponentType, ffmpegStreamFormat, jpegQuality, secret }: {
126 | devMode: boolean,
127 | width: number,
128 | height: number,
129 | fps: number,
130 | serverPort: number,
131 | durationFrames: number,
132 | renderId: number,
133 | userData: unknown,
134 | videoComponentType?: VideoComponentType | undefined,
135 | ffmpegStreamFormat: FFmpegStreamFormat,
136 | jpegQuality: number,
137 | secret: string,
138 | }) {
139 | // @ts-expect-error todo
140 | window.isPuppeteer = true;
141 |
142 | const container = document.getElementById('root');
143 | const root = createRoot(container!);
144 |
145 | // https://github.com/reactwg/react-18/discussions/5
146 | return new Promise
((resolve) => {
147 | root.render(
148 | ,
163 | );
164 | });
165 | }
166 |
167 | // https://github.com/puppeteer/puppeteer/issues/422#issuecomment-708142856
168 | async function haveFontsLoaded() {
169 | const ready = await document.fonts.ready;
170 | return ready.status === 'loaded';
171 | }
172 |
173 | export type AwaitDomRenderSettled = typeof awaitDomRenderSettled;
174 | export type SetupReact = typeof setupReact;
175 | export type HaveFontsLoaded = typeof haveFontsLoaded;
176 |
177 | window.awaitDomRenderSettled = awaitDomRenderSettled;
178 | window.haveFontsLoaded = haveFontsLoaded;
179 | window.setupReact = setupReact;
180 |
--------------------------------------------------------------------------------
/packages/builder/src/renderer.ts:
--------------------------------------------------------------------------------
1 | import { CaptureMethod } from 'reactive-video/dist/types.js';
2 | import { fileURLToPath } from 'node:url';
3 |
4 | import workerpool from 'workerpool';
5 |
6 | import type { RenderPartBaseParams, RenderPartParams, WorkerEvent, WorkerProgress } from './poolWorker.js';
7 | import { type Logger } from './index.js';
8 |
9 | export default async ({ concurrency, captureMethod, headless, extraPuppeteerArgs, customOutputFfmpegArgs, numRetries, logger, tempDir, extensionPath, puppeteerCaptureFormat, ffmpegPath, fps, enableFfmpegLog, enablePerFrameLog, width, height, devMode, port, durationFrames, userData, videoComponentType, ffmpegStreamFormat, jpegQuality, secret, distPath, failOnWebErrors, sleepTimeBeforeCapture, frameRenderTimeout, browserExePath, keepBrowserRunning }: RenderPartBaseParams & {
10 | concurrency: number,
11 | captureMethod: CaptureMethod,
12 | headless: boolean,
13 | logger: Logger,
14 | }) => {
15 | const workerType = 'process';
16 | const pool = workerpool.pool(fileURLToPath(new URL('poolWorker.js', import.meta.url)), { maxWorkers: concurrency, minWorkers: concurrency, maxQueueSize: 0, workerType });
17 |
18 | function renderPart({ partNum, partStart, partEnd, onProgress }: {
19 | partNum: number,
20 | partStart: number,
21 | partEnd: number,
22 | onProgress: (p: WorkerProgress) => void,
23 | }) {
24 | const task = pool.exec(
25 | 'renderPart',
26 | [{ captureMethod, headless, extraPuppeteerArgs, customOutputFfmpegArgs, numRetries, tempDir, extensionPath, puppeteerCaptureFormat, ffmpegPath, fps, enableFfmpegLog, enablePerFrameLog, width, height, devMode, port, durationFrames, userData, videoComponentType, ffmpegStreamFormat, jpegQuality, secret, distPath, failOnWebErrors, sleepTimeBeforeCapture, frameRenderTimeout, partNum, partStart, partEnd, browserExePath, keepBrowserRunning } satisfies RenderPartParams],
27 | {
28 | on: ({ event, data }: WorkerEvent) => {
29 | if (event === 'progress') {
30 | onProgress(data);
31 | } else if (event === 'log') {
32 | logger[data.level](...data.args);
33 | }
34 | },
35 | },
36 | );
37 |
38 | const abort = () => task.cancel();
39 |
40 | return { promise: task, abort };
41 | }
42 |
43 | const terminateRenderers = async () => pool.terminate();
44 |
45 | return {
46 | renderPart,
47 | terminateRenderers,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/packages/builder/src/server.ts:
--------------------------------------------------------------------------------
1 | import express, { NextFunction, Request, Response } from 'express';
2 | import bodyParser from 'body-parser';
3 | import asyncHandler from 'express-async-handler';
4 | import basicAuth from 'express-basic-auth';
5 | import cookieParser from 'cookie-parser';
6 | import morgan from 'morgan';
7 | import assert from 'node:assert';
8 | import { stat } from 'node:fs/promises';
9 |
10 | import { uriifyPath } from './util.js';
11 |
12 | import { readFrame, cleanupAll as cleanupVideoProcessors, readVideoStreamsMetadata } from './videoServer.js';
13 | import type { Logger } from './index.js';
14 |
15 | // In the future we may need to start multiple express servers if that becomes a bottleneck
16 | export default async function serve({ logger, ffmpegPath, ffprobePath, serveStaticPath, serveRoot, port, secret, enableRequestLog = false }: {
17 | logger: Logger,
18 | ffmpegPath: string,
19 | ffprobePath: string,
20 | serveStaticPath?: string,
21 | serveRoot?: boolean,
22 | port: number,
23 | secret: string,
24 | enableRequestLog?: boolean,
25 | }) {
26 | const app = express();
27 |
28 | app.use(cookieParser());
29 |
30 | if (enableRequestLog) {
31 | app.use(morgan('API :method :url :status :response-time ms - :res[content-length]', { stream: { write: (message) => logger.info(message.trim()) } }));
32 | }
33 |
34 | app.use((req, res, next) => {
35 | if (req.cookies['reactive-video-secret'] === secret) {
36 | next();
37 | return;
38 | }
39 | if (req.query['secret'] === secret) {
40 | res.cookie('reactive-video-secret', secret, { httpOnly: true });
41 | next();
42 | return;
43 | }
44 | basicAuth({ users: { 'reactive-video': secret } })(req, res, next);
45 | });
46 |
47 | app.use(bodyParser.json());
48 |
49 | // less cpu when disabled?
50 | app.set('etag', false);
51 |
52 | app.get('/api/frame', asyncHandler(async (req, res) => {
53 | try {
54 | // todo type check this
55 | const params = Object.fromEntries(Object.entries(req.query).map(([key, val]) => {
56 | if (['fps', 'width', 'height', 'fileFps', 'time', 'streamIndex', 'renderId', 'jpegQuality'].includes(key)) return [key, val != null && typeof val === 'string' ? parseFloat(val) : undefined];
57 | if (key === 'scale') return [key, val === 'true'];
58 | return [key, val];
59 | }));
60 | const { ffmpegStreamFormat } = params;
61 | // console.log(params);
62 | const ret = await readFrame({ params, ffmpegPath, logger });
63 | // eslint-disable-next-line unicorn/prefer-switch
64 | if (ffmpegStreamFormat === 'png') {
65 | res.set({ 'content-type': 'image/png' });
66 | assert('buffer' in ret);
67 | res.send(ret.buffer);
68 | } else if (ffmpegStreamFormat === 'raw') {
69 | assert('stream' in ret);
70 | ret.stream!.pipe(res);
71 | } else if (ffmpegStreamFormat === 'jpeg') {
72 | res.set({ 'content-type': 'image/jpeg' });
73 | assert('buffer' in ret);
74 | res.send(ret.buffer);
75 | } else {
76 | throw new Error('Invalid type');
77 | }
78 | } catch (err) {
79 | logger.error('Server read frame error', err);
80 | res.sendStatus(400);
81 | }
82 | }));
83 |
84 | app.post('/api/read-video-metadata', asyncHandler(async (req, res) => {
85 | // todo type check body
86 | const uri = uriifyPath(req.body.path);
87 | try {
88 | await stat(uri);
89 | } catch (err) {
90 | if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
91 | logger.error('File not found', uri);
92 | res.sendStatus(404);
93 | return;
94 | }
95 | }
96 |
97 | res.send(await readVideoStreamsMetadata({ ffprobePath, path: uri, streamIndex: req.body.streamIndex }));
98 | }));
99 |
100 | if (serveStaticPath) app.use(express.static(serveStaticPath));
101 |
102 | if (serveRoot) app.use('/root', express.static('/'));
103 |
104 | // must be last
105 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
106 | app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
107 | logger.error('Request error', err);
108 | res.status(500).send('Internal server error');
109 | });
110 |
111 | let server: ReturnType;
112 | await new Promise((resolve) => {
113 | server = app.listen(port, resolve);
114 | });
115 |
116 | if (enableRequestLog) logger.info('HTTP server listening on port', port);
117 |
118 | const stop = async () => {
119 | logger.info('Stopping HTTP server');
120 | server.close();
121 | await cleanupVideoProcessors();
122 | };
123 | return { port, stop };
124 | }
125 |
--------------------------------------------------------------------------------
/packages/builder/src/splitStream.test.ts:
--------------------------------------------------------------------------------
1 | import { Readable } from 'node:stream';
2 | import assert from 'node:assert';
3 | // eslint-disable-next-line import/no-extraneous-dependencies
4 | import { describe, test, expect } from 'vitest';
5 |
6 | import createSplitter from './splitStream.js';
7 |
8 | const jpegSoi = Buffer.from([0xFF, 0xD8]);
9 |
10 | async function readableToBuffer(readable: Readable) {
11 | let result = Buffer.alloc(0);
12 | // eslint-disable-next-line no-restricted-syntax
13 | for await (const chunk of readable) {
14 | result = Buffer.concat([result, chunk]);
15 | }
16 | return result;
17 | }
18 |
19 | describe('splits properly on delim', () => {
20 | const cases = [
21 | ['2 byte delim', jpegSoi],
22 | ['1 byte delim', Buffer.from([0x00])],
23 | ] as const;
24 |
25 | test.each(cases)('data has no delim, %p', async (_name, soi) => {
26 | const readableStream = Readable.from(function* gen() {
27 | yield Buffer.from([0x01, 0x01]);
28 | }());
29 |
30 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
31 | const subStream = await awaitNextSplit();
32 | assert(subStream);
33 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x01]));
34 |
35 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
36 | });
37 |
38 | test.each(cases)('data only has 1 delim, %p', async (_name, soi) => {
39 | const readableStream = Readable.from(function* gen() {
40 | yield soi;
41 | yield Buffer.from([0xFF, 0xFF]);
42 | }());
43 |
44 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
45 | const subStream = await awaitNextSplit();
46 | assert(subStream);
47 | expect(await readableToBuffer(subStream)).toEqual(Buffer.concat([soi, Buffer.from([0xFF, 0xFF])]));
48 |
49 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
50 | });
51 |
52 | test.each(cases)('data has longer chunk with multiple delims, %p', async (_name, soi) => {
53 | const readableStream = Readable.from(function* gen() {
54 | yield Buffer.concat([soi, Buffer.from([0xFF, 0xFF]), soi, soi, Buffer.from([0xD8])]);
55 | yield soi;
56 | yield Buffer.concat([Buffer.from([0xFF, 0xFF])]);
57 | }());
58 |
59 | const { awaitNextSplit } = createSplitter({
60 | readableStream,
61 | splitOnDelim: soi,
62 | });
63 |
64 | let subStream = await awaitNextSplit();
65 | assert(subStream);
66 | expect(await readableToBuffer(subStream)).toEqual(Buffer.concat([soi, Buffer.from([0xFF, 0xFF])]));
67 | subStream = await awaitNextSplit();
68 | assert(subStream);
69 | expect(await readableToBuffer(subStream)).toEqual(soi);
70 | subStream = await awaitNextSplit();
71 | assert(subStream);
72 | expect(await readableToBuffer(subStream)).toEqual(Buffer.concat([soi, Buffer.from([0xD8])]));
73 | subStream = await awaitNextSplit();
74 | assert(subStream);
75 | expect(await readableToBuffer(subStream)).toEqual(Buffer.concat([soi, Buffer.from([0xFF, 0xFF])]));
76 |
77 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
78 | });
79 |
80 | test.each(cases)('data has one byte only, %p', async (_name, soi) => {
81 | const readableStream = Readable.from(function* gen() {
82 | yield Buffer.from([soi[0]!]);
83 | }());
84 |
85 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
86 | const subStream = await awaitNextSplit();
87 | assert(subStream);
88 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([soi[0]!]));
89 |
90 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
91 | });
92 |
93 | test.each(cases)('data is delim only, %p', async (_name, soi) => {
94 | const readableStream = Readable.from(function* gen() {
95 | yield soi;
96 | }());
97 |
98 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
99 | const subStream = await awaitNextSplit();
100 | assert(subStream);
101 | expect(await readableToBuffer(subStream)).toEqual(soi);
102 |
103 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
104 | });
105 |
106 | test.each(cases)('data has delim at end, %p', async (_name, soi) => {
107 | const readableStream = Readable.from(function* gen() {
108 | yield Buffer.from([0xFF, 0xFF]);
109 | yield soi;
110 | }());
111 |
112 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
113 | let subStream = await awaitNextSplit();
114 | assert(subStream);
115 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0xFF, 0xFF]));
116 | subStream = await awaitNextSplit();
117 | assert(subStream);
118 | expect(await readableToBuffer(subStream)).toEqual(soi);
119 |
120 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
121 | });
122 |
123 | test.each(cases)('no data, %p', async (_name, soi) => {
124 | const readableStream = Readable.from([]);
125 |
126 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: soi });
127 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
128 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
129 | });
130 | });
131 |
132 | test('splits on delim in the middle between two chunks', async () => {
133 | const readableStream = Readable.from(function* gen() {
134 | yield Buffer.from([jpegSoi[0]!]);
135 | yield Buffer.from([jpegSoi[1]!]);
136 | }());
137 |
138 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: jpegSoi });
139 | const subStream = await awaitNextSplit();
140 | assert(subStream);
141 | expect(await readableToBuffer(subStream)).toEqual(jpegSoi);
142 |
143 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
144 | });
145 |
146 | test('splits on delim when only part of delim at end of chunk', async () => {
147 | const readableStream = Readable.from(function* gen() {
148 | yield Buffer.from([jpegSoi[0]!]);
149 | }());
150 |
151 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnDelim: jpegSoi });
152 | const subStream = await awaitNextSplit();
153 | assert(subStream);
154 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([jpegSoi[0]!]));
155 |
156 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
157 | });
158 |
159 | describe('split on lengths', () => {
160 | const cases = [
161 | [1],
162 | [2],
163 | [3],
164 | ];
165 | test.each(cases)('no data, length %p', async (length) => {
166 | const readableStream = Readable.from([]);
167 |
168 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: length });
169 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
170 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
171 | });
172 |
173 | test.each(cases)('1 byte, length %p', async (length) => {
174 | const readableStream = Readable.from(Buffer.from([0x01]));
175 |
176 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: length });
177 | const subStream = await awaitNextSplit();
178 | assert(subStream);
179 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01]));
180 |
181 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
182 | });
183 |
184 | test.each(cases)('2 bytes, length %p', async (length) => {
185 | const readableStream = Readable.from(Buffer.from([0x01, 0x02]));
186 |
187 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: length });
188 | let subStream = await awaitNextSplit();
189 |
190 | if (length === 1) {
191 | assert(subStream);
192 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01]));
193 | subStream = await awaitNextSplit();
194 | assert(subStream);
195 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x02]));
196 | } else {
197 | assert(subStream);
198 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x02]));
199 | }
200 |
201 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
202 | });
203 |
204 | test.each(cases)('3 bytes, separate chunks, length %p', async (length) => {
205 | const readableStream = Readable.from(function* gen() {
206 | yield Buffer.from([0x01, 0x02]);
207 | yield Buffer.from([0x03]);
208 | }());
209 |
210 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: length });
211 | let subStream = await awaitNextSplit();
212 |
213 | if (length === 1) {
214 | assert(subStream);
215 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01]));
216 | subStream = await awaitNextSplit();
217 | assert(subStream);
218 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x02]));
219 | subStream = await awaitNextSplit();
220 | assert(subStream);
221 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x03]));
222 | } else if (length === 2) {
223 | assert(subStream);
224 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x02]));
225 | subStream = await awaitNextSplit();
226 | assert(subStream);
227 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x03]));
228 | } else {
229 | assert(subStream);
230 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x02, 0x03]));
231 | }
232 |
233 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
234 | });
235 | });
236 |
237 | test('split on length 2 (5 bytes)', async () => {
238 | const readableStream = Readable.from(Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]));
239 |
240 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: 2 });
241 | let subStream = await awaitNextSplit();
242 |
243 | assert(subStream);
244 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x02]));
245 | subStream = await awaitNextSplit();
246 | assert(subStream);
247 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x03, 0x04]));
248 | subStream = await awaitNextSplit();
249 | assert(subStream);
250 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x05]));
251 |
252 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
253 | });
254 |
255 | test('split on length 2 (4 bytes)', async () => {
256 | const readableStream = Readable.from(Buffer.from([0x01, 0x02, 0x03, 0x04]));
257 |
258 | const { awaitNextSplit } = createSplitter({ readableStream, splitOnLength: 2 });
259 | let subStream = await awaitNextSplit();
260 |
261 | assert(subStream);
262 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x01, 0x02]));
263 | subStream = await awaitNextSplit();
264 | assert(subStream);
265 | expect(await readableToBuffer(subStream)).toEqual(Buffer.from([0x03, 0x04]));
266 |
267 | await expect(awaitNextSplit()).rejects.toThrow('Stream has ended');
268 | });
269 |
--------------------------------------------------------------------------------
/packages/builder/src/splitStream.ts:
--------------------------------------------------------------------------------
1 | import { PassThrough, Readable } from 'node:stream';
2 | import { once } from 'node:events';
3 | import debug from 'debug';
4 |
5 | const log = debug('splitStream');
6 |
7 | /*
8 | TODO improve this code
9 |
10 | Create npm module
11 | https://github.com/sindresorhus/awesome-nodejs/blob/main/readme.md#streams
12 | https://github.com/thejmazz/awesome-nodejs-streams
13 |
14 | transform stream instead that spits out streams? (yo dawg, I heard you like streams)
15 |
16 | similar:
17 | https://github.com/mpotra/split-to-streams (doesn't seem to preserve delim)
18 | https://github.com/watson/stream-chopper (size/time based)
19 | https://github.com/maxogden/binary-split
20 | https://github.com/131/stream-split
21 | https://github.com/hgranlund/node-binary-split
22 | https://github.com/mcollina/split2
23 |
24 | https://github.com/enobufs/node-mjpeg-reader (file only)
25 | https://github.com/mmaelzer/mjpeg-consumer
26 | https://github.com/eugeneware/png-split-stream
27 | https://github.com/kevinGodell/pipe2jpeg (seems a bit shady due to using internals `_readableState.pipesCount`)
28 |
29 | Good tests:
30 | https://github.com/watson/stream-chopper/blob/master/test.js
31 | */
32 | export default function createSplitter({ readableStream, splitOnDelim, splitOnLength }: {
33 | readableStream: Readable,
34 | splitOnDelim?: Buffer,
35 | splitOnLength?: number,
36 | }) {
37 | if ((splitOnDelim == null || splitOnDelim.length === 0) && !splitOnLength) {
38 | throw new TypeError('Specify one of splitOnDelim or splitOnLength');
39 | }
40 |
41 | let readableEnded = false;
42 |
43 | readableStream.on('end', () => {
44 | log('readableStream end');
45 | readableEnded = true;
46 | });
47 |
48 | readableStream.on('close', () => {
49 | log('readableStream close');
50 | readableEnded = true;
51 | });
52 |
53 | let outStream: PassThrough | undefined;
54 | let error: Error;
55 | let newStreamOnNextWrite = false;
56 | let awaitingNewStreams: { resolve: (s: PassThrough | undefined) => void, reject: (err: Error) => void }[] = [];
57 | const newStreams: PassThrough[] = [];
58 |
59 | function rejectAwaitingStreams() {
60 | if (awaitingNewStreams.length > 0) {
61 | awaitingNewStreams.forEach(({ reject }) => reject(error));
62 | awaitingNewStreams = [];
63 | }
64 | }
65 |
66 | function newStream() {
67 | log('newStream');
68 | if (outStream) outStream.end();
69 | outStream = new PassThrough();
70 |
71 | if (awaitingNewStreams.length > 0) {
72 | awaitingNewStreams.forEach(({ resolve }) => resolve(outStream));
73 | awaitingNewStreams = [];
74 | return;
75 | }
76 | newStreams.push(outStream);
77 | }
78 |
79 | async function awaitNextSplit() {
80 | log('awaitNextSplit');
81 | if (newStreams.length > 0) return newStreams.shift();
82 |
83 | if (error) throw error;
84 |
85 | return new Promise((resolve, reject) => {
86 | awaitingNewStreams.push({ resolve, reject });
87 | });
88 | }
89 |
90 | async function safeWrite(buf: Buffer) {
91 | if (buf.length === 0) return;
92 | if (newStreamOnNextWrite || !outStream) {
93 | newStreamOnNextWrite = false;
94 | newStream();
95 | }
96 | log('safeWrite', buf);
97 | if (!outStream!.write(buf)) await once(outStream!, 'drain');
98 | log('safeWrite done');
99 | }
100 |
101 | // I'm not proud of this, but it works
102 | // https://www.youtube.com/watch?v=2Y1JX6jHoDU
103 | // TODO: Hitting an issue "Stream has ended" when running this in EC2 (but not on mac os and not with png/raw), not sure why but something is wrong here
104 | async function splitStreamOnDelim() {
105 | let workingBuf = Buffer.alloc(0);
106 |
107 | async function readMore() {
108 | log('readMore');
109 | if (readableEnded) {
110 | log('readableEnded');
111 | return false;
112 | }
113 |
114 | let val = readableStream.read();
115 | if (val != null) {
116 | log('read', val);
117 | workingBuf = Buffer.concat([workingBuf, val]);
118 | return true;
119 | }
120 |
121 | log('waiting for readableStream readable');
122 | await once(readableStream, 'readable');
123 | log('readableStream is now readable');
124 |
125 | val = readableStream.read();
126 | if (val != null) {
127 | log('read', val);
128 | workingBuf = Buffer.concat([workingBuf, val]);
129 | }
130 |
131 | log('read', !!val);
132 | return !!val;
133 | }
134 |
135 | readableStream.pause();
136 |
137 | let hasMoreData = true;
138 |
139 | while (workingBuf.length > 0 || hasMoreData) {
140 | const delimSearchOffset = 0;
141 | let delimIndex = -1;
142 |
143 | while (delimIndex < 0) {
144 | // delimSearchOffset = workingBuf.length;
145 |
146 | // eslint-disable-next-line no-await-in-loop
147 | hasMoreData = await readMore();
148 |
149 | delimIndex = workingBuf.indexOf(splitOnDelim!, delimSearchOffset);
150 |
151 | if (delimIndex >= 0) break;
152 |
153 | if (!hasMoreData) {
154 | log({ hasMoreData });
155 | break;
156 | }
157 |
158 | // Optimization: flush data to output stream if delim not split across 2 chunks
159 | // Delim may be split across 2 (or more) chunks. If not, we can write out data now
160 | // console.log(splitOnDelim, workingBuf, splitOnDelim.includes(workingBuf[workingBuf.length - 1]))
161 | if (!splitOnDelim!.includes(workingBuf.at(-1)!)) {
162 | // eslint-disable-next-line no-await-in-loop
163 | await safeWrite(workingBuf);
164 | workingBuf = Buffer.alloc(0);
165 | }
166 | }
167 |
168 | if (delimIndex >= 0) {
169 | log('delimIndex', delimIndex);
170 | const partBefore = workingBuf.slice(0, delimIndex);
171 | const partAfter = workingBuf.slice(delimIndex + splitOnDelim!.length);
172 |
173 | // eslint-disable-next-line no-await-in-loop
174 | await safeWrite(partBefore);
175 | newStreamOnNextWrite = true;
176 | // eslint-disable-next-line no-await-in-loop
177 | await safeWrite(splitOnDelim!);
178 |
179 | workingBuf = partAfter;
180 | } else {
181 | // eslint-disable-next-line no-await-in-loop
182 | await safeWrite(workingBuf);
183 | workingBuf = Buffer.alloc(0);
184 | }
185 | }
186 | }
187 |
188 | // ...nor this
189 | async function splitStreamOnLength() {
190 | let bytesReadSinceLastSplit = 0;
191 |
192 | // eslint-disable-next-line no-restricted-syntax
193 | for await (let chunk of readableStream) {
194 | while (chunk.length > 0) {
195 | const splitOnChunkByte = splitOnLength! - bytesReadSinceLastSplit;
196 |
197 | // console.log(splitOnChunkByte, chunk.length);
198 | if (splitOnChunkByte >= 0 && splitOnChunkByte < chunk.length) {
199 | const partBefore = chunk.slice(0, splitOnChunkByte);
200 | const partAfter = chunk.slice(splitOnChunkByte);
201 |
202 | // eslint-disable-next-line no-await-in-loop
203 | await safeWrite(partBefore);
204 | newStreamOnNextWrite = true;
205 | // eslint-disable-next-line no-await-in-loop
206 | chunk = partAfter;
207 | bytesReadSinceLastSplit = 0;
208 | } else {
209 | // eslint-disable-next-line no-await-in-loop
210 | await safeWrite(chunk);
211 | bytesReadSinceLastSplit += chunk.length;
212 | chunk = Buffer.alloc(0);
213 | }
214 | }
215 | }
216 | }
217 |
218 | (async () => {
219 | try {
220 | if (splitOnLength != null) {
221 | await splitStreamOnLength();
222 | } else if (splitOnDelim != null) {
223 | await splitStreamOnDelim();
224 | }
225 | if (outStream) outStream.end();
226 |
227 | error = new Error('Stream has ended');
228 | } catch (err) {
229 | const err2 = err instanceof Error ? err : new Error(String(err));
230 | if (outStream) outStream.destroy(err2);
231 | error = err2;
232 | } finally {
233 | rejectAwaitingStreams();
234 | }
235 | })();
236 |
237 | return {
238 | awaitNextSplit,
239 | };
240 | }
241 |
--------------------------------------------------------------------------------
/packages/builder/src/util.test.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { describe, test, expect } from 'vitest';
3 | import { splitIntoParts } from './util';
4 |
5 | describe('util', () => {
6 | test('splitIntoParts', () => {
7 | expect(splitIntoParts({ startFrame: 0, durationFrames: 2, concurrency: 1 })).toEqual([[0, 2]]);
8 | expect(splitIntoParts({ startFrame: 0, durationFrames: 1, concurrency: 1 })).toEqual([[0, 1]]);
9 | // todo
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/packages/builder/src/util.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'node:crypto';
2 | import { promisify } from 'node:util';
3 | import { fileURLToPath } from 'node:url';
4 |
5 | const randomBytes = promisify(crypto.randomBytes);
6 |
7 | export async function generateSecret() {
8 | return (await randomBytes(32)).toString('base64');
9 | }
10 |
11 | export const uriifyPath = (path: string) => (path.startsWith('file://') ? fileURLToPath(path) : path);
12 |
13 | export function splitIntoParts({ startFrame, durationFrames, concurrency }: {
14 | startFrame: number,
15 | durationFrames: number,
16 | concurrency: number,
17 | }) {
18 | const partLength = Math.floor(durationFrames / concurrency);
19 | const parts = Array.from({ length: concurrency }).fill(undefined).map((_v, i) => {
20 | const ret: [number, number] = [i * partLength, (i + 1) * partLength];
21 | return ret;
22 | });
23 | const remainder = durationFrames % concurrency;
24 | if (remainder > 0) parts.at(-1)![1] += remainder;
25 | return parts.map(([partStart, partEnd]) => {
26 | const ret: [number, number] = [startFrame + partStart, startFrame + partEnd];
27 | return ret;
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/packages/builder/src/videoServer.ts:
--------------------------------------------------------------------------------
1 | import stringify from 'json-stable-stringify';
2 | import { execa, ResultPromise } from 'execa';
3 | // @ts-expect-error todo
4 | import pngSplitStream from 'png-split-stream';
5 | import binarySplit from 'binary-split';
6 | import { Readable } from 'node:stream';
7 | import assert from 'node:assert';
8 |
9 | import { FfmpegBaseParams, FFmpegParams, FFmpegStreamFormat } from 'reactive-video/dist/types.js';
10 |
11 | import createSplitter from './splitStream.js';
12 | import { uriifyPath } from './util.js';
13 | import type { Logger } from './index.js';
14 |
15 | const videoProcesses: Record | undefined,
21 | time?: number,
22 | busy?: boolean,
23 | readNextFrame?: () => Promise<{ buffer: Buffer } | { stream: Readable | undefined }>,
24 | }> = {};
25 |
26 | function createFfmpeg({ ffmpegPath, cutFrom, fps, uri: uriOrPath, width, height, scale, fileFps, streamIndex, ffmpegStreamFormat, jpegQuality }: FfmpegBaseParams & {
27 | ffmpegPath: string,
28 | cutFrom: number,
29 | }) {
30 | const fileFrameDuration = 1 / fileFps;
31 |
32 | const uri = uriifyPath(uriOrPath);
33 |
34 | const filters = [
35 | `fps=${fps}`,
36 | ];
37 | if (scale) filters.push(`scale=${width}:${height}`);
38 |
39 | function getJpegQuality(percent: number) {
40 | const val = Math.max(Math.min(Math.round(2 + ((31 - 2) * (100 - percent)) / 100), 31), 2);
41 | return val;
42 | }
43 |
44 | const args = [
45 | '-hide_banner',
46 | // '-loglevel', 'panic',
47 |
48 | // Transparency
49 | // '-vcodec', 'libvpx',
50 |
51 | // It seems that if -ss is just a tiny fraction higher than the desired frame start time, ffmpeg will instead cut from the next frame. So we subtract a bit of the duration of the input file's frames
52 | '-ss', String(Math.max(0, cutFrom - (fileFrameDuration * 0.1))),
53 |
54 | '-noautorotate',
55 |
56 | '-i', uri,
57 |
58 | '-an',
59 |
60 | '-vf', filters.join(','),
61 | '-map', `0:v:${streamIndex}`,
62 |
63 | ...(ffmpegStreamFormat === 'raw' ? [
64 | '-pix_fmt', 'rgba',
65 | '-vcodec', 'rawvideo',
66 | ] : []),
67 |
68 | ...(ffmpegStreamFormat === 'png' ? [
69 | '-pix_fmt', 'rgba',
70 | '-vcodec', 'png',
71 | ] : []),
72 |
73 | ...(ffmpegStreamFormat === 'jpeg' ? [
74 | ...(jpegQuality != null ? ['-q:v', String(getJpegQuality(jpegQuality))] : []),
75 | '-pix_fmt', 'rgba',
76 | '-vcodec', 'mjpeg',
77 | ] : []),
78 |
79 | '-f', 'image2pipe',
80 | '-',
81 | ];
82 |
83 | // console.log(args.join(' '));
84 |
85 | return execa(ffmpegPath, args, { encoding: 'buffer', buffer: false, stderr: 'ignore' });
86 | }
87 |
88 | async function cleanupProcess(key: string) {
89 | if (!key) return undefined;
90 | const videoProcess = videoProcesses[key];
91 | if (videoProcess && videoProcess.process) {
92 | videoProcess.process.kill();
93 | delete videoProcesses[key]?.process;
94 | }
95 | return videoProcess && videoProcess.process;
96 | }
97 |
98 | function createFrameReader({ process, ffmpegStreamFormat, width, height }: {
99 | process: ResultPromise<{
100 | encoding: 'buffer',
101 | buffer: false,
102 | stderr: 'ignore',
103 | }>,
104 | ffmpegStreamFormat: FFmpegStreamFormat,
105 | width: number,
106 | height: number,
107 | }) {
108 | if (ffmpegStreamFormat === 'raw') {
109 | const channels = 4;
110 | const frameByteSize = width * height * channels;
111 |
112 | const { awaitNextSplit } = createSplitter({ readableStream: process.stdout!, splitOnLength: frameByteSize });
113 |
114 | return {
115 | readNextFrame: async () => ({ stream: await awaitNextSplit() }),
116 | };
117 | }
118 |
119 | if (ffmpegStreamFormat === 'jpeg') {
120 | const jpegSoi = Buffer.from([0xFF, 0xD8]); // JPEG start sequence
121 | const splitter = binarySplit(jpegSoi);
122 |
123 | const stream = process.stdout!.pipe(splitter);
124 | stream.pause();
125 |
126 | return {
127 | readNextFrame: async () => new Promise<{ buffer: Buffer }>((resolve, reject) => {
128 | function onError(err: unknown) {
129 | reject(err);
130 | }
131 | function onData(jpegFrameWithoutSoi: Buffer) {
132 | // each 'data' event contains one of the frames from the video as a single chunk
133 | // todo improve this
134 | const jpegFrame = Buffer.concat([jpegSoi, jpegFrameWithoutSoi]);
135 | resolve({ buffer: jpegFrame });
136 | stream.pause();
137 | stream.off('error', onError);
138 | }
139 |
140 | stream.resume();
141 | stream.once('data', onData);
142 | stream.once('error', onError);
143 | }),
144 | };
145 | }
146 |
147 | if (ffmpegStreamFormat === 'png') {
148 | const stream = process.stdout!.pipe(pngSplitStream());
149 | stream.pause();
150 |
151 | return {
152 | readNextFrame: async () => new Promise<{ buffer: Buffer }>((resolve, reject) => {
153 | function onError(err: unknown) {
154 | reject(err);
155 | }
156 | function onData(pngFrame: Buffer) {
157 | // each 'data' event contains one of the frames from the video as a single chunk
158 | resolve({ buffer: pngFrame });
159 | stream.pause();
160 | stream.off('error', onError);
161 | }
162 |
163 | stream.resume();
164 | stream.once('data', onData);
165 | stream.once('error', onError);
166 | }),
167 | };
168 | }
169 |
170 | throw new Error('Invalid ffmpegStreamFormat');
171 | }
172 |
173 | export async function readFrame({ params, ffmpegPath, logger }: {
174 | params: FFmpegParams,
175 | ffmpegPath: string,
176 | logger: Logger,
177 | }) {
178 | let process: ResultPromise<{
179 | encoding: 'buffer',
180 | buffer: false,
181 | stderr: 'ignore',
182 | }> | undefined;
183 |
184 | const { fps, uri, width, height, scale, fileFps, time = 0, streamIndex, ffmpegStreamFormat, jpegQuality } = params;
185 |
186 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
187 | const { time: ignored, ...allExceptTime } = params;
188 | const key = stringify(allExceptTime);
189 |
190 | // console.log(videoProcesses[key] && videoProcesses[key].time, time);
191 |
192 | if (!videoProcesses[key]) videoProcesses[key] = {};
193 |
194 | // without this check, it could lead to bugs if concurrent reads lead to overlapping readNextFrame calls
195 | // https://github.com/mifi/reactive-video/issues/12
196 | if (videoProcesses[key]!.busy) throw new Error(`Busy processing previous frame: ${key} ${time}`);
197 |
198 | // if (Math.random() < 0.2) throw new Error('Test error');
199 |
200 | videoProcesses[key]!.busy = true;
201 |
202 | try {
203 | const frameDuration = 1 / fps;
204 |
205 | // Assume up to half a frame off is the same frame
206 | const isSameFrame = (time1: number, time2: number) => Math.abs(time1 - time2) < frameDuration * 0.5;
207 |
208 | const { time: processTime } = videoProcesses[key]!;
209 | if (processTime != null && isSameFrame(processTime, time)) {
210 | // console.log('Reusing ffmpeg');
211 | videoProcesses[key]!.time = time; // prevent the times from drifting apart
212 | } else {
213 | logger.log('createFfmpeg', key, time);
214 | // console.log({ processTime: videoProcesses[key] ? videoProcesses[key].time : undefined, time, frameDuration });
215 |
216 | // Parameters changed (or time is not next frame). need to restart encoding
217 | cleanupProcess(key); // in case only time has changed, cleanup old process
218 |
219 | process = createFfmpeg({ ffmpegPath, fps, uri, width, height, scale, fileFps, cutFrom: time, streamIndex, ffmpegStreamFormat, jpegQuality });
220 |
221 | const { readNextFrame } = createFrameReader({ process, ffmpegStreamFormat, width, height });
222 |
223 | videoProcesses[key] = {
224 | ...videoProcesses[key],
225 | process,
226 | time,
227 | readNextFrame: async () => Promise.race([readNextFrame(), new Promise((_resolve, reject) => process!.catch(reject))]),
228 | };
229 | }
230 |
231 | const videoProcess = videoProcesses[key];
232 |
233 | videoProcess!.time = (videoProcess!.time ?? 0) + frameDuration;
234 |
235 | const frame = await videoProcess!.readNextFrame!();
236 |
237 | return frame;
238 | } catch (err) {
239 | if (process) {
240 | try {
241 | await process;
242 | } catch (err2) {
243 | if (!(err2 instanceof Error && 'killed' in err2 && err2.killed)) {
244 | logger.error('ffmpeg error', (err2 as Error).message);
245 | cleanupProcess(key);
246 | }
247 | }
248 | }
249 | throw err;
250 | } finally {
251 | videoProcesses[key]!.busy = false;
252 | }
253 | }
254 |
255 | export async function cleanupAll() {
256 | await Promise.allSettled(Object.keys(videoProcesses).map((key) => cleanupProcess(key)));
257 | }
258 |
259 | export async function readVideoFormatMetadata({ ffprobePath, path }: { ffprobePath: string, path: string }) {
260 | const { stdout } = await execa(ffprobePath, [
261 | '-of', 'json', '-show_entries', 'format', '-i', path,
262 | ]);
263 |
264 | const { format } = JSON.parse(stdout);
265 |
266 | let duration: number | undefined = parseFloat(format.duration);
267 | if (Number.isNaN(duration)) duration = undefined;
268 |
269 | return { duration };
270 | }
271 |
272 | export async function readVideoStreamsMetadata({ ffprobePath, path, streamIndex }: {
273 | ffprobePath: string,
274 | path: string,
275 | streamIndex: number,
276 | }) {
277 | const { stdout } = await execa(ffprobePath, [
278 | '-of', 'json', '-show_entries', 'stream', '-i', path,
279 | ]);
280 |
281 | const { streams }: { streams: { codec_type: string, avg_frame_rate: string, width: number, height: number }[] } = JSON.parse(stdout);
282 | const videoStreams = streams.filter((s) => s.codec_type === 'video');
283 | const stream = videoStreams[streamIndex];
284 | assert(stream, 'Stream not found');
285 |
286 | const { width, height, avg_frame_rate: avgFrameRate } = stream;
287 | const frameRateSplit = avgFrameRate.split('/');
288 | const frameRateCalculated = parseInt(frameRateSplit[0]!, 10) / parseInt(frameRateSplit[1]!, 10);
289 | const fps = Number.isNaN(frameRateCalculated) ? undefined : frameRateCalculated;
290 |
291 | return { width, height, fps };
292 | }
293 |
294 | export async function readDurationFrames({ ffprobePath, path, streamIndex = 0 }: {
295 | ffprobePath: string,
296 | path: string,
297 | streamIndex?: number,
298 | }) {
299 | const { stdout } = await execa(ffprobePath, ['-v', 'error', '-select_streams', `v:${streamIndex}`, '-count_packets', '-show_entries', 'stream=nb_read_packets', '-of', 'csv=p=0', path]);
300 | return parseInt(stdout, 10);
301 | }
302 |
--------------------------------------------------------------------------------
/packages/builder/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"],
3 | "include": [
4 | "src/**/*"
5 | ],
6 | "compilerOptions": {
7 | "rootDir": "src",
8 | "outDir": "dist",
9 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
10 | "composite": true,
11 | "allowJs": false,
12 | "checkJs": false,
13 |
14 | // taken from @tsconfig/vite-react
15 | "target": "ES2020",
16 | "useDefineForClassFields": true,
17 | "lib": ["ES2023", "DOM", "DOM.Iterable"],
18 | "module": "ESNext",
19 |
20 | /* Bundler mode */
21 | "moduleResolution": "Bundler",
22 | "moduleDetection": "force",
23 | "jsx": "react-jsx",
24 |
25 | "paths": {
26 | "reactive-video/dist/*": ["../frontend/src/*"],
27 | "reactive-video": ["../frontend/src/index.ts"],
28 | }
29 | },
30 | "references": [{ "path": "../frontend" }]
31 | }
32 |
--------------------------------------------------------------------------------
/packages/e2e/custom.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: string;
3 | export default content;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/e2e/fonts.css:
--------------------------------------------------------------------------------
1 | * {
2 | -webkit-font-smoothing: antialiased;
3 | }
4 |
5 | body {
6 | line-height: 1.2;
7 | }
8 |
9 | @font-face {
10 | font-family: 'Oswald bold';
11 | src: url('../../reactive-video-assets/Oswald-Bold.ttf') format('truetype');
12 | }
13 |
14 | @font-face {
15 | font-family: 'Oswald regular';
16 | src: url('../../reactive-video-assets/Oswald-Regular.ttf') format('truetype');
17 | }
--------------------------------------------------------------------------------
/packages/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@reactive-video/e2e",
3 | "description": "End-to-end tests for Reactive Video",
4 | "version": "0.0.0",
5 | "dependencies": {
6 | "@reactive-video/builder": "workspace:^",
7 | "execa": "*",
8 | "reactive-video": "workspace:^",
9 | "vitest": "*"
10 | },
11 | "peerDependencies": {
12 | "react": "18"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/e2e/segments.css:
--------------------------------------------------------------------------------
1 | /* @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); */
2 |
3 | body {
4 | line-height: 1.2;
5 | }
6 |
7 | * {
8 | -webkit-font-smoothing: antialiased;
9 | }
10 |
11 | @font-face {
12 | font-family: 'Girassol';
13 | font-style: normal;
14 | font-weight: 400;
15 | src: url(../../reactive-video-assets/Girassol-Regular.woff2) format('woff2');
16 | }
--------------------------------------------------------------------------------
/packages/e2e/src/segments/ReactiveVideo.tsx:
--------------------------------------------------------------------------------
1 | import { useVideo, Segment, Image } from 'reactive-video';
2 |
3 | import '../../segments.css';
4 |
5 | // svg made by https://www.instagram.com/nack___thanakorn/
6 | // eslint-disable-next-line import/no-relative-packages
7 | import pumpkin from '../../../../reactive-video-assets/pumpkin.svg';
8 |
9 | const Counter = () => {
10 | const { currentFrame, currentTime, durationFrames, durationTime, progress, video } = useVideo();
11 |
12 | const seg = { currentFrame, currentTime, durationFrames, durationTime, progress };
13 |
14 | const renderTimes = (times: Pick, 'currentFrame' | 'currentTime' | 'durationFrames' | 'durationTime' | 'progress'>) => (
15 | <>
16 | {times.currentFrame} f {times.currentTime.toFixed(2)} s
17 | / {times.durationFrames} f {times.durationTime.toFixed(2)} s
18 | {(times.progress * 100).toFixed(0)} %
19 | >
20 | );
21 |
22 | return (
23 |
24 |
Segment:
25 | {renderTimes(seg)}
26 |
27 |
Video:
28 | {renderTimes(video)}
29 |
30 | );
31 | };
32 |
33 | export default function ReactiveVideo() {
34 | const { currentTime } = useVideo();
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
54 |
55 |
({progress.toFixed(2)}
)} />
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/packages/e2e/src/simple/ReactiveVideo.tsx:
--------------------------------------------------------------------------------
1 | import { useVideo } from 'reactive-video';
2 |
3 | import '../../style.css';
4 |
5 | export default function ReactiveVideo() {
6 | const { currentTime } = useVideo();
7 |
8 | return (
9 |
10 | {currentTime}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/packages/e2e/src/video-simple/ReactiveVideo.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import { Video, useVideo } from 'reactive-video';
3 |
4 |
5 | export interface UserData {
6 | videoUri: string,
7 | }
8 |
9 | export default function ReactiveVideo() {
10 | const { width, height, userData: { videoUri } } = useVideo();
11 |
12 | const videoStyle: CSSProperties = { width, height, position: 'absolute' };
13 |
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/packages/e2e/src/video/ReactiveVideo.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 | import { Video, useVideo, Image } from 'reactive-video';
3 |
4 | import '../../fonts.css';
5 |
6 | // todo
7 | // eslint-disable-next-line import/no-relative-packages
8 | import flag from '../../../../reactive-video-assets/Flag_of_Thailand.svg';
9 |
10 | export interface UserData {
11 | videoUri: string,
12 | title: string,
13 | description: string,
14 | }
15 |
16 | export default function ReactiveVideo() {
17 | const { width, height, userData: { videoUri, title, description }, currentTime } = useVideo();
18 |
19 | const blurAmount = Math.floor((width * 0.008) / (1 + currentTime));
20 | const brightnessAmount = Math.max(1, 1 + 5 * (1 - currentTime));
21 | const videoStyle: CSSProperties = { width, height, position: 'absolute', filter: `blur(${blurAmount.toFixed(5)}px) brightness(${brightnessAmount})` };
22 |
23 | return (
24 |
25 | {/* scaleToWidth makes the test much faster */}
26 |
27 |
28 |
29 |
30 |
{title}
31 |
{description}
32 |
33 |
34 |
35 |
36 | Visit Now
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/packages/e2e/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | line-height: 1.2;
3 | }
4 |
5 | * {
6 | -webkit-font-smoothing: antialiased;
7 | }
8 |
9 | @font-face {
10 | font-family: 'Oswald';
11 | font-style: normal;
12 | font-weight: 400;
13 | src: url(../../reactive-video-assets/Oswald-Regular.ttf) format('truetype');
14 | }
--------------------------------------------------------------------------------
/packages/e2e/testManual.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path';
2 | import { pathToFileURL } from 'node:url';
3 |
4 | import { testAssetsDir, edit, outputDir, getEditor } from './util.js';
5 |
6 | const editor = getEditor({ logger: console });
7 |
8 | const reactVideo = join(__dirname, 'video', 'ReactiveVideo.js');
9 |
10 | const inputVideoPath = join(testAssetsDir, 'Koh Kood.mp4');
11 | const userData = { videoUri: pathToFileURL(inputVideoPath), title: 'Koh Kood', description: 'Paradise in Thailand' };
12 |
13 | const output = join(outputDir, 'reactive-video.mov');
14 |
15 | const { durationTime } = await editor.readVideoMetadata({ path: inputVideoPath });
16 |
17 | const width = 1280;
18 | const height = 720;
19 | const fps = 30;
20 |
21 | /* await editor.preview({
22 | reactVideo,
23 | userData,
24 |
25 | durationTime,
26 | width,
27 | height,
28 | fps,
29 | videoComponentType: 'html',
30 | });
31 | return; */
32 |
33 | await edit(editor, {
34 | concurrency: 4,
35 | reactVideo,
36 | width,
37 | height,
38 | fps,
39 | durationTime,
40 | userData,
41 | output,
42 | // enableRequestLog: true,
43 | });
44 |
--------------------------------------------------------------------------------
/packages/e2e/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest", "@tsconfig/node18/tsconfig.json"],
3 | "include": [
4 | "src/**/*",
5 | ],
6 | "compilerOptions": {
7 | "rootDir": "src",
8 | "outDir": "dist",
9 | "allowJs": false,
10 | "checkJs": false,
11 |
12 | // taken from @tsconfig/vite-react
13 | "target": "ES2020",
14 | "useDefineForClassFields": true,
15 | "lib": ["ES2023", "DOM", "DOM.Iterable"],
16 | "module": "ESNext",
17 |
18 | /* Bundler mode */
19 | "moduleResolution": "Bundler",
20 | "moduleDetection": "force",
21 | "jsx": "react-jsx",
22 |
23 | "paths": {
24 | "reactive-video/dist/*": ["../frontend/src/*"],
25 | "reactive-video": ["../frontend/src/index.ts"],
26 | "@reactive-video/builder/dist/*": ["../builder/src/*"],
27 | "@reactive-video/builder": ["../builder/src/index.ts"],
28 | }
29 | },
30 | "references": [
31 | { "path": "../frontend" },
32 | { "path": "../builder" }
33 | ],
34 | "files": ["custom.d.ts"]
35 | }
36 |
--------------------------------------------------------------------------------
/packages/e2e/util.ts:
--------------------------------------------------------------------------------
1 | import { join, dirname } from 'node:path';
2 | import { mkdir, rm } from 'node:fs/promises';
3 | import { fileURLToPath } from 'node:url';
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | import { execa } from 'execa';
6 | // eslint-disable-next-line import/no-extraneous-dependencies
7 | import { Browser, computeExecutablePath } from '@puppeteer/browsers';
8 |
9 | import Editor from '@reactive-video/builder';
10 |
11 | // eslint-disable-next-line no-underscore-dangle
12 | const __dirname = dirname(fileURLToPath(import.meta.url));
13 |
14 | export const testAssetsDir = join(__dirname, '../../reactive-video-assets');
15 | export const imageSnapshotsDir = join(testAssetsDir, 'test-image-snapshots');
16 | export const videoSnapshotsDir = join(testAssetsDir, 'test-video-snapshots');
17 | export const outputDir = join(testAssetsDir, 'test-output');
18 |
19 | export const workDir = join(__dirname, 'test-workdir');
20 | const tempDir = join(workDir, 'reactive-video-tmp');
21 |
22 | export async function initTests() {
23 | await mkdir(workDir, { recursive: true });
24 | await mkdir(outputDir, { recursive: true });
25 | }
26 |
27 | export async function cleanupTests() {
28 | await rm(workDir, { recursive: true, force: true });
29 | }
30 |
31 | export async function edit(editor: ReturnType, opts: Parameters['edit']>[0]) {
32 | return editor.edit({
33 | tempDir,
34 | numRetries: 0,
35 | enableFrameCountCheck: true,
36 | ...opts,
37 | });
38 | }
39 |
40 | const chromeBuildId = '131.0.6778.85';
41 |
42 | const browserExePath = computeExecutablePath({ cacheDir: './browser', browser: Browser.CHROME, buildId: chromeBuildId });
43 |
44 | // override logger: null to get log output
45 | export const getEditor = (opts?: Omit[0], 'ffmpegPath' | 'ffprobePath' | 'browserExePath'>) => Editor({
46 | ffmpegPath: 'ffmpeg',
47 | ffprobePath: 'ffprobe',
48 | browserExePath,
49 | logger: null,
50 | // devMode: true,
51 | ...opts,
52 | });
53 |
54 | export async function checkVideosMatch(path1: string, referenceVideoPath: string, threshold = 0.98) {
55 | const { stdout } = await execa('ffmpeg', ['-loglevel', 'error', '-i', path1, '-i', referenceVideoPath, '-lavfi', 'ssim=stats_file=-', '-f', 'null', '-']);
56 | const ok = stdout.split('\n').every((line) => {
57 | const match = line.match(/^n:(\d+) Y:[\d.]+ U:[\d.]+ V:[\d.]+ All:([\d.]+)/);
58 | if (!match) return false;
59 | const frameNum = parseFloat(match[1]!);
60 | const similarity = parseFloat(match[2]!);
61 | if (similarity < threshold) {
62 | console.warn('All similarities:', stdout);
63 | console.warn('Similarity was off', { frameNum, similarity });
64 |
65 | return false;
66 | }
67 | return true;
68 | });
69 |
70 | if (!ok) {
71 | console.log('Generating visual diff');
72 | const args = ['-i', referenceVideoPath, '-i', path1, '-filter_complex', 'blend=all_mode=difference', '-c:v', 'libx264', '-crf', '18', '-c:a', 'copy', '-y', join(`${path1}-diff.mov`)];
73 | console.log(args.join(' '));
74 | await execa('ffmpeg', args);
75 | }
76 |
77 | return ok;
78 | }
79 |
--------------------------------------------------------------------------------
/packages/e2e/video.test.ts:
--------------------------------------------------------------------------------
1 | import { join, dirname } from 'node:path';
2 | import { pathToFileURL, fileURLToPath } from 'node:url';
3 | import { readFile } from 'node:fs/promises';
4 | // eslint-disable-next-line import/no-extraneous-dependencies
5 | import { describe, beforeAll, afterEach, test, expect } from 'vitest';
6 |
7 | // eslint-disable-next-line import/no-extraneous-dependencies
8 | import { configureToMatchImageSnapshot } from 'jest-image-snapshot';
9 | // eslint-disable-next-line import/no-extraneous-dependencies
10 | import sharp from 'sharp';
11 |
12 | import { initTests, cleanupTests, imageSnapshotsDir, videoSnapshotsDir, testAssetsDir, workDir, edit, outputDir, getEditor, checkVideosMatch } from './util.js';
13 | import { UserData as VideoUserData } from './src/video/ReactiveVideo.js';
14 | import { UserData as SimpleVideoUserData } from './src/video-simple/ReactiveVideo.js';
15 |
16 | // eslint-disable-next-line no-underscore-dangle
17 | const reactiveVideoRoot = join(dirname(fileURLToPath(import.meta.url)), 'dist');
18 |
19 | const getFileNameForTest = (ext: string) => `${(expect.getState().currentTestName ?? '').replaceAll(/[^\dA-Za-z]/g, '')}.${ext}`;
20 | const getOutputPath = (ext: string) => join(outputDir, getFileNameForTest(ext));
21 | const getVideoSnapshotPath = (ext: string) => join(videoSnapshotsDir, getFileNameForTest(ext));
22 |
23 | const enableDebugLogging = process.env['RUNNER_DEBUG'] === '1';
24 |
25 | describe('render videos', () => {
26 | beforeAll(async () => {
27 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ customSnapshotsDir: imageSnapshotsDir, comparisonMethod: 'ssim', failureThresholdType: 'percent' });
28 | expect.extend({ toMatchImageSnapshot });
29 |
30 | await initTests();
31 | });
32 |
33 | const timeout = 60000;
34 |
35 | test('render, throws error on missing video', async () => {
36 | const editor = getEditor({ logger: console });
37 |
38 | const reactVideo = join(reactiveVideoRoot, 'video', 'ReactiveVideo');
39 |
40 | const inputVideoPath = '/nonexistent-video';
41 | const userData: VideoUserData = { videoUri: pathToFileURL(inputVideoPath).toString(), title: 'Title', description: 'Description' };
42 |
43 | const outPathPng = getOutputPath('png');
44 |
45 | const promise = edit(editor, {
46 | reactVideo,
47 | ffmpegStreamFormat: 'png',
48 | puppeteerCaptureFormat: 'png',
49 | width: 1920,
50 | height: 1080,
51 | durationFrames: 1,
52 | userData,
53 | output: outPathPng,
54 |
55 | enableRequestLog: true,
56 | enablePerFrameLog: true,
57 | enableFfmpegLog: enableDebugLogging,
58 |
59 | // headless: false,
60 | // keepBrowserRunning: 60000,
61 | });
62 |
63 | await expect(promise).rejects.toThrow(/Video server responded HTTP http:\/\/localhost:\d+\/api\/read-video-metadata 404 Not Found/);
64 | }, timeout);
65 |
66 | test('render single frame from video', async () => {
67 | const editor = getEditor({ logger: console });
68 |
69 | const reactVideo = join(reactiveVideoRoot, 'video', 'ReactiveVideo');
70 |
71 | const inputVideoPath = join(testAssetsDir, 'Koh Kood.mp4');
72 | const userData: VideoUserData = { videoUri: pathToFileURL(inputVideoPath).toString(), title: 'Koh Kood', description: 'Paradise in Thailand' };
73 |
74 | const outPathPng = getOutputPath('png');
75 | await edit(editor, {
76 | reactVideo,
77 | ffmpegStreamFormat: 'png',
78 | puppeteerCaptureFormat: 'png',
79 | width: 1920,
80 | height: 1080,
81 | durationFrames: 1,
82 | startFrame: 30,
83 | userData,
84 | output: outPathPng,
85 |
86 | enableRequestLog: true,
87 | enablePerFrameLog: true,
88 | enableFfmpegLog: enableDebugLogging,
89 | });
90 |
91 | expect(await readFile(outPathPng)).toMatchImageSnapshot({ failureThreshold: 0.2 }); // font rendering is slightly different on macos/linux
92 | }, timeout);
93 |
94 | test('vertical video', async () => {
95 | const editor = getEditor({ logger: console });
96 |
97 | const reactVideo = join(reactiveVideoRoot, 'video-simple', 'ReactiveVideo');
98 |
99 | const inputVideoPath = join(testAssetsDir, 'square-container-aspect-1-2.mp4');
100 | const userData: SimpleVideoUserData = { videoUri: pathToFileURL(inputVideoPath).toString() };
101 |
102 | const outPathPng = getOutputPath('png');
103 | await edit(editor, {
104 | reactVideo,
105 | ffmpegStreamFormat: 'png',
106 | puppeteerCaptureFormat: 'png',
107 | width: 200,
108 | height: 400,
109 | durationFrames: 1,
110 | startFrame: 0,
111 | userData,
112 | output: outPathPng,
113 |
114 | enableRequestLog: true,
115 | enablePerFrameLog: true,
116 | enableFfmpegLog: enableDebugLogging,
117 | });
118 |
119 | expect(await readFile(outPathPng)).toMatchImageSnapshot({ failureThreshold: 0.0001 });
120 | }, timeout);
121 |
122 | // Test a simple page without any resources, to see that it works even with an empty asyncRegistry
123 | test('render single frame, simple', async () => {
124 | const editor = getEditor({ logger: console });
125 |
126 | const reactVideo = join(reactiveVideoRoot, 'simple', 'ReactiveVideo');
127 |
128 | const outPathPng = getOutputPath('png');
129 |
130 | await edit(editor, {
131 | reactVideo,
132 | ffmpegStreamFormat: 'png',
133 | puppeteerCaptureFormat: 'png',
134 | durationFrames: 1,
135 | width: 1920,
136 | height: 1080,
137 | output: outPathPng,
138 |
139 | enableRequestLog: true,
140 | enablePerFrameLog: true,
141 | enableFfmpegLog: enableDebugLogging,
142 | });
143 |
144 | // try also jpeg because it's faster, so commonly used
145 | const outPathJpeg = getOutputPath('jpeg');
146 |
147 | await edit(editor, {
148 | reactVideo,
149 | ffmpegStreamFormat: 'jpeg',
150 | puppeteerCaptureFormat: 'jpeg',
151 | durationFrames: 1,
152 | width: 1920,
153 | height: 1080,
154 | output: outPathJpeg,
155 | });
156 |
157 | // convert the jpeg to png for snapshot comparison
158 | const outPathPng2 = join(workDir, 'jpeg-converted.png');
159 | await sharp(outPathJpeg).toFile(outPathPng2);
160 |
161 | expect(await readFile(outPathPng)).toMatchImageSnapshot({ failureThreshold: 0.0001 }); // font rendering is slightly different on macos/linux
162 | expect(await readFile(outPathPng2)).toMatchImageSnapshot({ failureThreshold: 0.0001 });
163 | }, timeout);
164 |
165 | const customOutputFfmpegArgs = ['-c:v', 'libx264', '-crf', '30'];
166 |
167 | test('render video with overlay', async () => {
168 | const editor = getEditor({ logger: console });
169 | // const editor = getEditor({ logger: console });
170 |
171 | const reactVideo = join(reactiveVideoRoot, 'video', 'ReactiveVideo');
172 |
173 | const inputVideoPath = join(testAssetsDir, 'Koh Kood.mp4');
174 | const userData: VideoUserData = { videoUri: pathToFileURL(inputVideoPath).toString(), title: 'Koh Kood', description: 'Paradise in Thailand' };
175 |
176 | const { width: inputWidth, height: inputHeight, fps, durationTime } = await editor.readVideoMetadata({ path: inputVideoPath });
177 |
178 | expect(inputWidth).toBe(2720);
179 | expect(inputHeight).toBe(1530);
180 |
181 | const output = getOutputPath('mov');
182 | await edit(editor, {
183 | concurrency: 4,
184 | reactVideo,
185 | width: 1280,
186 | height: 720,
187 | fps,
188 | durationTime,
189 | userData,
190 | customOutputFfmpegArgs,
191 | output,
192 |
193 | enableRequestLog: enableDebugLogging,
194 | enablePerFrameLog: enableDebugLogging,
195 | enableFfmpegLog: enableDebugLogging,
196 | });
197 |
198 | expect(await checkVideosMatch(output, getVideoSnapshotPath('mov'), 0.96)).toBeTruthy();
199 | }, timeout);
200 |
201 | test('render segments', async () => {
202 | const editor = getEditor({ logger: console });
203 |
204 | const width = 1280;
205 | const height = 720;
206 | const durationFrames = 91;
207 | // const durationFrames = 591;
208 |
209 | const output = getOutputPath('mov');
210 |
211 | await edit(editor, {
212 | concurrency: 4,
213 | reactVideo: join(reactiveVideoRoot, 'segments', 'ReactiveVideo'),
214 |
215 | width,
216 | height,
217 | fps: 25,
218 | durationFrames,
219 | customOutputFfmpegArgs,
220 | output,
221 |
222 | enableRequestLog: enableDebugLogging,
223 | enablePerFrameLog: enableDebugLogging,
224 | enableFfmpegLog: enableDebugLogging,
225 | });
226 |
227 | expect(await checkVideosMatch(output, getVideoSnapshotPath('mov'), 0.96)).toBeTruthy();
228 | }, timeout);
229 |
230 | afterEach(async () => {
231 | await cleanupTests();
232 | });
233 | });
234 |
--------------------------------------------------------------------------------
/packages/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@reactive-video/examples",
3 | "version": "1.0.5",
4 | "private": true,
5 | "dependencies": {
6 | "@reactive-video/builder": "*",
7 | "execa": "5",
8 | "jpeg-js": "^0.4.3"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Reactive Video
2 |
3 | See https://github.com/mifi/reactive-video
--------------------------------------------------------------------------------
/packages/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reactive-video",
3 | "version": "4.0.1",
4 | "files": [
5 | "dist"
6 | ],
7 | "exports": {
8 | "types": "./dist/index.d.ts",
9 | "default": "./dist/index.js"
10 | },
11 | "license": "GPL-3.0-only",
12 | "description": "Create videos using React!",
13 | "author": "Mikael Finstad ",
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/mifi/reactive-video.git"
17 | },
18 | "scripts": {
19 | "clean": "rimraf dist"
20 | },
21 | "devDependencies": {
22 | "@types/react": "^18.2.34",
23 | "rimraf": "*",
24 | "typescript": "*",
25 | "vitest": "*"
26 | },
27 | "peerDependencies": {
28 | "react": "18"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/packages/frontend/src/api.ts:
--------------------------------------------------------------------------------
1 | import { API, FFmpegParams } from './types';
2 |
3 | export interface ReadMetadataParams {
4 | path: string,
5 | streamIndex: number,
6 | }
7 |
8 | export default ({ serverPort, renderId, secret }: {
9 | serverPort: number,
10 | renderId?: number,
11 | secret: string
12 | }): API => {
13 | const baseUrl = `http://localhost:${serverPort}`;
14 |
15 | async function request(path: string, opts: RequestInit = {}) {
16 | const { headers } = opts;
17 |
18 | function base64() {
19 | const username = 'reactive-video';
20 | return btoa(`${username}:${secret}`);
21 | }
22 |
23 | const url = `${baseUrl}${path}`;
24 | const response = await fetch(url, {
25 | ...opts,
26 | headers: {
27 | ...headers,
28 | Authorization: `Basic ${base64()}`,
29 | },
30 | });
31 |
32 | if (!response.ok) throw new Error(`Video server responded HTTP ${url} ${response.status} ${response.statusText}`);
33 | return response;
34 | }
35 |
36 | const getQs = (params: FFmpegParams) => new URLSearchParams(
37 | Object.fromEntries(
38 | Object.entries({ ...params, renderId, secret })
39 | .filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]),
40 | ),
41 | ).toString();
42 |
43 | function getVideoFrameUrl(params: FFmpegParams) {
44 | return `${baseUrl}/api/frame?${getQs(params)}`;
45 | }
46 |
47 | async function readVideoFrame(params: FFmpegParams) {
48 | return request(`/api/frame?${getQs(params)}`);
49 | }
50 |
51 | async function readVideoMetadata({ path, streamIndex }: { path: string, streamIndex: number }) {
52 | return request('/api/read-video-metadata', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, streamIndex } satisfies ReadMetadataParams) });
53 | }
54 |
55 | function getProxiedAssetUrl(uri: string) {
56 | if (uri.startsWith('file://')) return `${baseUrl}/root/${uri.replace(/^file:\/\//, '')}`;
57 | return uri;
58 | }
59 |
60 | return {
61 | getVideoFrameUrl,
62 | readVideoFrame,
63 | readVideoMetadata,
64 | getProxiedAssetUrl,
65 | };
66 | };
67 |
--------------------------------------------------------------------------------
/packages/frontend/src/asyncRegistry.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { useVideo } from './contexts';
4 |
5 | const framesDone = new Set();
6 |
7 | type ComponentName = string
8 |
9 | let promises: Promise<{ component: ComponentName, currentFrame: number } | undefined>[] = [];
10 |
11 | export async function awaitAsyncRenders(frameNumber: number | null) {
12 | // console.log('awaitAsyncRenders', promises.length)
13 | if (framesDone.has(frameNumber)) throw new Error(`Tried to awaitAsyncRenders already done frame ${frameNumber}`);
14 | try {
15 | return (await Promise.all(promises)).filter((v) => v != null); // we don't need to return those that are undefined (will be converted to null by JSON.stringify)
16 | } finally {
17 | promises = [];
18 | framesDone.add(frameNumber);
19 | }
20 | }
21 |
22 | export function useAsyncRenderer(
23 | fn: () => Promise | [() => Promise, (() => void)],
24 | deps: unknown[],
25 | component: ComponentName,
26 | ) {
27 | const { video: { currentFrame } } = useVideo();
28 |
29 | // console.log('useAsyncRenderer', component, currentFrame)
30 |
31 | if (framesDone.has(currentFrame)) {
32 | throw new Error(`Tried to useAsyncRenderer already done frame ${currentFrame}`);
33 | }
34 |
35 | let resolve: (a: { component: ComponentName, currentFrame: number } | undefined) => void;
36 | let reject: (a: Error) => void;
37 |
38 | // add promises immediately when calling the hook so we don't lose them
39 | promises.push(new Promise((resolve2, reject2) => {
40 | resolve = resolve2;
41 | reject = reject2;
42 | }));
43 |
44 | let hasTriggeredAsyncEffect = false;
45 |
46 | useEffect(() => {
47 | // eslint-disable-next-line react-hooks/exhaustive-deps
48 | hasTriggeredAsyncEffect = true;
49 |
50 | // allow returning an array with a cleanup function too
51 | const arrayOrPromise = fn();
52 | let cleanup;
53 | let promise: Promise;
54 | if (Array.isArray(arrayOrPromise)) {
55 | const [fn2] = arrayOrPromise;
56 | [, cleanup] = arrayOrPromise;
57 | promise = fn2();
58 | } else {
59 | promise = arrayOrPromise;
60 | }
61 |
62 | (async () => {
63 | try {
64 | // console.log('waiting for', component);
65 | await promise;
66 | // console.log('finishRender', component, currentFrame);
67 | resolve({ component, currentFrame });
68 | } catch (err) {
69 | // console.error('Render error for', component, currentFrame, err.message);
70 | reject(err instanceof Error ? err : new Error('An unknown error occurred'));
71 | }
72 | })();
73 |
74 | return cleanup;
75 | }, deps);
76 |
77 | useEffect(() => {
78 | // if this render had no deps changes triggering the above useEffect, we need to just resolve the promise
79 | if (!hasTriggeredAsyncEffect) {
80 | resolve(undefined);
81 | }
82 | });
83 | }
84 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/FFmpegVideo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { useVideo } from '../contexts';
4 | import { useAsyncRenderer } from '../asyncRegistry';
5 |
6 | // fetch seems to be faster than letting the fetch the src itself
7 | // but it seems to be causing sporadic blank (white) image
8 | const useFetch = false;
9 |
10 | type RestProps = React.DetailedHTMLProps, HTMLCanvasElement>
11 | & React.DetailedHTMLProps, HTMLImageElement>;
12 |
13 | export interface FFmpegVideoProps {
14 | src: string,
15 | scaleToWidth?: number,
16 | scaleToHeight?: number,
17 | streamIndex?: number,
18 | isPuppeteer?: boolean,
19 | }
20 |
21 | const FFmpegVideo = ({ src, scaleToWidth, scaleToHeight, streamIndex = 0, style, isPuppeteer = false, ...rest }: FFmpegVideoProps & RestProps) => {
22 | const { currentTime, fps, api, ffmpegStreamFormat, jpegQuality } = useVideo();
23 | if (api == null) throw new Error('No API in context');
24 |
25 | const canvasRef = useRef(null);
26 | const imgRef = useRef(null);
27 |
28 | const videoMetaCache = useRef>({});
29 |
30 | const ongoingRequestsRef = useRef>();
31 |
32 | useAsyncRenderer(() => {
33 | let objectUrl: string;
34 | let canceled = false;
35 |
36 | return [
37 | async () => {
38 | function drawOnCanvas(ctx: CanvasRenderingContext2D, rgbaImage: ArrayBuffer, w: number, h: number) {
39 | // https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
40 | // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData
41 | ctx.putImageData(new ImageData(new Uint8ClampedArray(rgbaImage), w, h), 0, 0);
42 | }
43 |
44 | // No need to flash white when preview
45 | if (isPuppeteer) {
46 | if (ffmpegStreamFormat === 'raw') {
47 | if (canvasRef.current == null) throw new Error('canvasRef was nullish');
48 | canvasRef.current.style.visibility = 'hidden';
49 | }
50 | if (['png', 'jpeg'].includes(ffmpegStreamFormat)) {
51 | if (imgRef.current == null) throw new Error('imgRef was nullish');
52 | imgRef.current.src = '';
53 | }
54 | }
55 |
56 | // Allow optional resizing
57 | let width = scaleToWidth;
58 | let height = scaleToHeight;
59 | const scale = !!(scaleToWidth || scaleToHeight);
60 |
61 | const cacheKey = src;
62 | if (!videoMetaCache.current[cacheKey]) {
63 | const videoMetadataResponse = await api.readVideoMetadata({ path: src, streamIndex });
64 | const meta = await videoMetadataResponse.json();
65 | videoMetaCache.current[cacheKey] = { width: meta.width, height: meta.height, fps: meta.fps };
66 | }
67 | const cached = videoMetaCache.current[cacheKey];
68 |
69 | if (!width) width = cached!.width;
70 | if (!height) height = cached!.height;
71 | const fileFps = cached!.fps;
72 |
73 | const ffmpegParams = { fps, uri: src, width, height, fileFps, scale, time: currentTime, streamIndex, ffmpegStreamFormat, jpegQuality };
74 |
75 | if (ffmpegStreamFormat === 'raw') {
76 | let fetchResponse: Response;
77 |
78 | if (isPuppeteer) {
79 | fetchResponse = await api.readVideoFrame(ffmpegParams);
80 | } else {
81 | // Throttle requests to server when only previewing
82 | if (!ongoingRequestsRef.current) {
83 | ongoingRequestsRef.current = (async () => {
84 | try {
85 | return await api.readVideoFrame(ffmpegParams);
86 | } finally {
87 | ongoingRequestsRef.current = undefined;
88 | }
89 | })();
90 | }
91 |
92 | fetchResponse = await ongoingRequestsRef.current;
93 | }
94 |
95 | const blob = await fetchResponse.blob();
96 |
97 | if (canceled) return;
98 |
99 | const canvas = canvasRef.current;
100 | if (canvas == null) throw new Error('canvas was nullish');
101 |
102 | canvas.width = width;
103 | canvas.height = height;
104 |
105 | const ctx = canvas.getContext('2d');
106 | if (ctx == null) throw new Error('ctx was null');
107 |
108 | const arrayBuffer = await blob.arrayBuffer();
109 | drawOnCanvas(ctx, arrayBuffer, width, height);
110 |
111 | if (canvasRef.current == null) throw new Error('canvas was nullish');
112 | canvasRef.current.style.visibility = '';
113 | return;
114 | }
115 |
116 | if (['png', 'jpeg'].includes(ffmpegStreamFormat)) {
117 | const loadPromise = new Promise((resolve, reject) => {
118 | if (imgRef.current == null) throw new Error('imgRef was nullish');
119 | imgRef.current.addEventListener('load', resolve);
120 | imgRef.current.addEventListener('error', () => reject(new Error(`FFmpegVideo frame image at time ${currentTime} failed to load`)));
121 | });
122 |
123 | await Promise.all([
124 | loadPromise,
125 | (async () => {
126 | if (useFetch) {
127 | const response = await fetch(new Request(api.getVideoFrameUrl(ffmpegParams)));
128 | objectUrl = URL.createObjectURL(await response.blob());
129 | if (imgRef.current == null) throw new Error('imgRef was nullish');
130 | imgRef.current.src = objectUrl;
131 | } else {
132 | if (imgRef.current == null) throw new Error('imgRef was nullish');
133 | imgRef.current.src = api.getVideoFrameUrl(ffmpegParams);
134 | }
135 | })(),
136 | ]);
137 | }
138 | },
139 | () => {
140 | if (!isPuppeteer) canceled = true;
141 | if (useFetch && objectUrl) URL.revokeObjectURL(objectUrl);
142 | },
143 | ];
144 | }, [src, currentTime, scaleToWidth, scaleToHeight, fps, api, streamIndex, isPuppeteer, ffmpegStreamFormat, jpegQuality], 'FFmpegVideo');
145 |
146 | // eslint-disable-next-line react/jsx-props-no-spreading
147 | if (ffmpegStreamFormat === 'raw') return ;
148 |
149 | // eslint-disable-next-line react/jsx-props-no-spreading,jsx-a11y/alt-text
150 | if (['png', 'jpeg'].includes(ffmpegStreamFormat)) return
;
151 |
152 | return null;
153 | };
154 |
155 | export default FFmpegVideo;
156 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/HTML5Video.tsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-line unicorn/filename-case
2 | import React, { useRef } from 'react';
3 |
4 | import { useVideo } from '../contexts';
5 | import { useAsyncRenderer } from '../asyncRegistry';
6 |
7 | const HTML5Video = (props: React.DetailedHTMLProps, HTMLVideoElement> & { src: string }) => {
8 | const { src, style, ...rest } = props;
9 |
10 | const { currentFrame, currentTime, fps } = useVideo();
11 |
12 | const videoRef = useRef(null);
13 |
14 | useAsyncRenderer(async () => new Promise((resolve, reject) => {
15 | // It seems that if currentTime just a tiny fraction lower than the desired frame start time, HTML5 video will instead seek to the previous frame. So we add a bit of the frame duration
16 | // See also FFmpegVideo backend
17 | const frameDuration = 1 / fps;
18 | const currentTimeCorrected = currentTime + frameDuration * 0.1;
19 |
20 | if (videoRef.current == null) throw new Error('videoRef was nullish');
21 | if (videoRef.current.src === src && videoRef.current.error == null) {
22 | if (Math.abs(videoRef.current.currentTime - currentTime) < frameDuration * 0.5) {
23 | if (videoRef.current.readyState >= 2) {
24 | resolve();
25 | return;
26 | }
27 |
28 | videoRef.current.addEventListener('loadeddata', () => resolve(), { once: true });
29 | return;
30 | }
31 |
32 | videoRef.current.currentTime = currentTimeCorrected;
33 | videoRef.current.addEventListener('canplay', () => resolve(), { once: true });
34 | return;
35 | }
36 |
37 | videoRef.current.addEventListener('canplay', () => resolve(), { once: true });
38 | videoRef.current.addEventListener('ended', () => resolve(), { once: true });
39 | videoRef.current.addEventListener('error', () => {
40 | reject(new Error(videoRef.current?.error ? `${videoRef.current.error.code} ${videoRef.current.error.message}` : 'Unknown HTML5 video error'));
41 | }, { once: true });
42 |
43 | videoRef.current.src = src;
44 | videoRef.current.currentTime = currentTimeCorrected;
45 | }), [src, currentFrame, fps, currentTime], 'HTML5Video');
46 |
47 | // object-fit to make it similar to canvas
48 | // eslint-disable-next-line jsx-a11y/media-has-caption,jsx-a11y/media-has-caption,react/jsx-props-no-spreading
49 | return ;
50 | };
51 |
52 | export default HTML5Video;
53 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/IFrame.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 |
3 | import { useAsyncRenderer } from '../asyncRegistry';
4 |
5 | const IFrame = (props: React.DetailedHTMLProps, HTMLIFrameElement>) => {
6 | const { src, onError, onLoad } = props;
7 |
8 | const errorRef = useRef<(a: Error) => void>();
9 | const loadRef = useRef<() => void>();
10 |
11 | const handleLoad: React.ReactEventHandler = (...args) => {
12 | if (loadRef.current) loadRef.current();
13 | onLoad?.(...args);
14 | };
15 |
16 | const handleError: React.ReactEventHandler = (...args) => {
17 | if (errorRef.current) errorRef.current(new Error(`IFrame failed to load ${src}`));
18 | onError?.(...args);
19 | };
20 |
21 | useAsyncRenderer(async () => new Promise((resolve, reject) => {
22 | errorRef.current = reject;
23 | loadRef.current = resolve;
24 | }), [src], 'IFrame');
25 |
26 | // eslint-disable-next-line jsx-a11y/iframe-has-title,react/jsx-props-no-spreading
27 | return ;
28 | };
29 |
30 | export default IFrame;
31 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Image.tsx:
--------------------------------------------------------------------------------
1 | import { useVideo } from '../contexts';
2 | import ImageInternal, { ImageProps } from './ImageInternal';
3 |
4 | const Image = (props: ImageProps) => {
5 | const { isPuppeteer, getProxiedAssetUrl } = useVideo();
6 | const { src, ...rest } = props;
7 |
8 | if (isPuppeteer) {
9 | // eslint-disable-next-line react/jsx-props-no-spreading
10 | return ;
11 | }
12 |
13 | const srcProxied = getProxiedAssetUrl(src);
14 | // eslint-disable-next-line react/jsx-props-no-spreading
15 | return ;
16 | };
17 |
18 | export default Image;
19 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/ImageInternal.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | import { useAsyncRenderer } from '../asyncRegistry';
4 |
5 | export type ImageProps = React.DetailedHTMLProps, HTMLImageElement>
6 | & { src: string };
7 |
8 | const ImageInternal = ({ src, _originalSrc, onError, onLoad, ...rest }: ImageProps & { _originalSrc: string }) => {
9 | const errorRef = useRef<(a: Error) => void>();
10 | const loadRef = useRef<() => void>();
11 |
12 | const handleLoad: React.ReactEventHandler = (...args) => {
13 | if (loadRef.current) loadRef.current();
14 | onLoad?.(...args);
15 | };
16 |
17 | const handleError: React.ReactEventHandler = (...args) => {
18 | if (errorRef.current) errorRef.current(new Error(`Image failed to load ${_originalSrc}`));
19 | onError?.(...args);
20 | };
21 |
22 | useAsyncRenderer(async () => new Promise((resolve, reject) => {
23 | errorRef.current = reject;
24 | loadRef.current = resolve;
25 | }), [src], 'ImageInternal');
26 |
27 | // eslint-disable-next-line jsx-a11y/iframe-has-title,jsx-a11y/alt-text,react/jsx-props-no-spreading
28 | return
;
29 | };
30 |
31 | export default ImageInternal;
32 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Segment.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, ReactNode, useMemo } from 'react';
2 | import { VideoContext, useVideo, calculateProgress } from '../contexts';
3 |
4 | const Segment = ({ children, start = 0, duration, render, override = true, cut = true }:
5 | PropsWithChildren<{
6 | start?: number, duration?: number, render?: (a: ReturnType) => ReactNode, override?: boolean, cut?: boolean
7 | }>) => {
8 | const videoContext = useVideo();
9 |
10 | const { currentFrame, getFrameTime } = videoContext;
11 |
12 | const currentFrameRelative = currentFrame - start;
13 | const currentTimeRelative = getFrameTime(currentFrameRelative);
14 |
15 | const segmentDurationFrames = duration != null ? duration : videoContext.durationFrames - start;
16 | const segmentDurationTime = getFrameTime(segmentDurationFrames);
17 |
18 | const segmentProgress = calculateProgress(currentFrameRelative, segmentDurationFrames);
19 |
20 | // Override the existing video context
21 | const videoContextNew = useMemo(() => ({
22 | ...videoContext,
23 |
24 | // Override these values:
25 | currentFrame: currentFrameRelative,
26 | currentTime: currentTimeRelative,
27 | durationFrames: segmentDurationFrames,
28 | durationTime: segmentDurationTime,
29 | progress: segmentProgress,
30 | }), [currentFrameRelative, currentTimeRelative, segmentDurationFrames, segmentDurationTime, videoContext, segmentProgress]);
31 |
32 | if (cut && (currentFrame < start || (duration != null && currentFrame >= start + duration))) return null;
33 |
34 | if (override) {
35 | return (
36 |
37 | {render && render(videoContextNew)}
38 | {children}
39 |
40 | );
41 | }
42 |
43 | return (
44 | <>
45 | {render && render(videoContextNew)}
46 | {children}
47 | >
48 | );
49 | };
50 |
51 | export default Segment;
52 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Video.tsx:
--------------------------------------------------------------------------------
1 | import FFmpegVideo from './FFmpegVideo';
2 | import HTML5Video from './HTML5Video';
3 | import { useVideo } from '../contexts';
4 |
5 | type RestProps = React.DetailedHTMLProps, HTMLCanvasElement>
6 | & React.DetailedHTMLProps, HTMLImageElement>
7 | & React.DetailedHTMLProps, HTMLVideoElement>;
8 |
9 | const Video = ({ src, htmlSrc, ...rest }: RestProps & { htmlSrc?: string, scaleToWidth?: number, scaleToHeight?: number }) => {
10 | const { videoComponentType, isPuppeteer, getProxiedAssetUrl } = useVideo();
11 |
12 | if (videoComponentType === 'html') {
13 | // eslint-disable-next-line react/jsx-props-no-spreading
14 | if (htmlSrc) return ;
15 |
16 | if (src == null) throw new Error('You must provide either htmlSrc or src');
17 |
18 | // If not puppeteer, proxy file:// URI through server as browser cannot handle file://
19 | const srcProxied = isPuppeteer ? src : getProxiedAssetUrl(src);
20 | // eslint-disable-next-line react/jsx-props-no-spreading
21 | return ;
22 | }
23 |
24 | if (videoComponentType === 'ffmpeg') {
25 | if (src == null) throw new Error('You must provide src');
26 |
27 | // eslint-disable-next-line react/jsx-props-no-spreading
28 | return ;
29 | }
30 |
31 | throw new Error(`Invalid videoComponentType ${videoComponentType}`);
32 | };
33 |
34 | export default Video;
35 |
--------------------------------------------------------------------------------
/packages/frontend/src/contexts.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useMemo, memo, PropsWithChildren } from 'react';
2 | import { API, FFmpegStreamFormat, VideoComponentType } from './types';
3 |
4 | interface VideoContextData {
5 | currentFrame: number,
6 | currentTime: number,
7 | durationFrames: number,
8 | durationTime: number,
9 | progress: number,
10 |
11 | video: {
12 | currentFrame: number,
13 | currentTime: number,
14 | durationFrames: number,
15 | durationTime: number,
16 | progress: number,
17 | },
18 |
19 | fps: number,
20 | width: number,
21 | height: number,
22 |
23 | getFrameTime: (a: number) => number,
24 | getTimeFrame: (a: number) => number,
25 |
26 | userData: UserData,
27 |
28 | api?: API | undefined,
29 | getProxiedAssetUrl: (a: string) => string,
30 |
31 | isPuppeteer: boolean,
32 | videoComponentType: VideoComponentType,
33 | ffmpegStreamFormat: FFmpegStreamFormat,
34 | jpegQuality?: number | undefined,
35 | }
36 |
37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
38 | export const VideoContext = React.createContext | null>(null);
39 |
40 | export const useVideo = () => {
41 | const videoContext = useContext | null>(VideoContext);
42 | if (videoContext == null) throw new Error('VideoContext not provided');
43 | return videoContext;
44 | };
45 |
46 | export const calculateProgress = (currentFrame: number, duration: number) => Math.max(0, Math.min(1, currentFrame / Math.max(1, duration - 1)));
47 |
48 | // eslint-disable-next-line react/display-name
49 | export const VideoContextProvider = memo(({
50 | currentFrame = 0,
51 | durationFrames,
52 | width = 800,
53 | height = 600,
54 | fps = 30,
55 | api,
56 | userData,
57 | videoComponentType = 'html',
58 | ffmpegStreamFormat = 'raw',
59 | jpegQuality,
60 | isPuppeteer = false,
61 | children,
62 | }: PropsWithChildren<{
63 | currentFrame?: number,
64 | durationFrames: number,
65 | width?: number,
66 | height?: number,
67 | fps?: number,
68 | api?: API,
69 | userData?: unknown,
70 | videoComponentType?: VideoComponentType,
71 | ffmpegStreamFormat?: FFmpegStreamFormat,
72 | jpegQuality?: number,
73 | isPuppeteer?: boolean,
74 | }>) => {
75 | const videoContext = useMemo(() => {
76 | const getFrameTime = (f: number) => f / fps;
77 | const getTimeFrame = (time: number) => Math.round(time * fps);
78 | const currentTime = getFrameTime(currentFrame);
79 | const durationTime = getFrameTime(durationFrames);
80 | const progress = calculateProgress(currentFrame, durationFrames);
81 |
82 | return {
83 | currentFrame,
84 | currentTime,
85 | durationFrames,
86 | durationTime,
87 | progress,
88 |
89 | // Global, never altered:
90 | video: {
91 | currentFrame,
92 | currentTime,
93 | durationFrames,
94 | durationTime,
95 | progress,
96 | },
97 |
98 | fps,
99 | width,
100 | height,
101 |
102 | getFrameTime,
103 | getTimeFrame,
104 |
105 | userData: userData || {},
106 |
107 | api,
108 | getProxiedAssetUrl: (src: string) => (api && api.getProxiedAssetUrl ? api.getProxiedAssetUrl(src) : src),
109 |
110 | isPuppeteer,
111 | videoComponentType,
112 | ffmpegStreamFormat,
113 | jpegQuality,
114 | };
115 | }, [currentFrame, durationFrames, fps, height, width, api, userData, isPuppeteer, videoComponentType, ffmpegStreamFormat, jpegQuality]);
116 |
117 | return (
118 |
119 | {children}
120 |
121 | );
122 | });
123 |
--------------------------------------------------------------------------------
/packages/frontend/src/index.ts:
--------------------------------------------------------------------------------
1 | export { useVideo, VideoContextProvider } from './contexts';
2 | export { useAsyncRenderer } from './asyncRegistry';
3 |
4 | export { awaitAsyncRenders } from './asyncRegistry';
5 |
6 | export { default as HTML5Video } from './components/HTML5Video';
7 | export { default as FFmpegVideo } from './components/FFmpegVideo';
8 | export { default as Video } from './components/Video';
9 | export { default as IFrame } from './components/IFrame';
10 | export { default as Image } from './components/Image';
11 | export { default as Segment } from './components/Segment';
12 | export { default as Api } from './api';
13 |
--------------------------------------------------------------------------------
/packages/frontend/src/types.ts:
--------------------------------------------------------------------------------
1 | export type VideoComponentType = 'html' | 'ffmpeg'
2 |
3 | export type PuppeteerCaptureFormat = 'png' | 'jpeg';
4 |
5 | export type CaptureMethod = 'screencast' | 'screenshot' | 'extension';
6 |
7 | export type FFmpegStreamFormat = 'raw' | 'jpeg' | 'png';
8 |
9 | export interface FfmpegBaseParams {
10 | fps: number,
11 | fileFps: number,
12 | uri: string,
13 | width: number,
14 | height: number,
15 | scale: boolean,
16 | streamIndex: number,
17 | ffmpegStreamFormat: FFmpegStreamFormat,
18 | jpegQuality?: number | undefined;
19 | }
20 |
21 | export interface FFmpegParams extends FfmpegBaseParams {
22 | time: number;
23 | }
24 |
25 | export interface API {
26 | getProxiedAssetUrl: (url: string) => string;
27 | getVideoFrameUrl: (params: FFmpegParams) => string;
28 | readVideoFrame: (params: FFmpegParams) => Promise;
29 | readVideoMetadata: (params: { path: string, streamIndex: number }) => Promise,
30 | }
31 |
--------------------------------------------------------------------------------
/packages/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@tsconfig/strictest"],
3 | "include": [
4 | "src/**/*"
5 | ],
6 | "compilerOptions": {
7 | "rootDir": "src",
8 | "outDir": "dist",
9 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
10 | "composite": true,
11 | "allowJs": false,
12 | "checkJs": false,
13 |
14 | // taken from @tsconfig/vite-react
15 | "target": "ES2020",
16 | "useDefineForClassFields": true,
17 | "lib": ["ES2023", "DOM", "DOM.Iterable"],
18 | "module": "ESNext",
19 |
20 | /* Bundler mode */
21 | "moduleResolution": "Bundler",
22 | "moduleDetection": "force",
23 | "jsx": "react-jsx",
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "packages/builder" },
4 | { "path": "packages/frontend" },
5 | { "path": "packages/e2e" },
6 | ],
7 | "files": []
8 | }
9 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'node',
6 | },
7 | });
8 |
--------------------------------------------------------------------------------