packages/recorder/src/Recorder.ts

  1. import Scene, { Animator, AnimatorOptions } from "scenejs";
  2. import { MediaSceneInfo } from "@scenejs/media";
  3. import { FileType, OnCapture, OnRequestCapture, OnProcess, RecordInfoOptions, RenderVideoOptions, RenderMediaInfoOptions, RecorderOptions, OnCaptureStart, OnProcessAudioStart, AnimatorLike } from "./types";
  4. import { createFFmpeg, fetchFile, FFmpeg } from "@ffmpeg/ffmpeg";
  5. import EventEmitter from "@scena/event-emitter";
  6. import { createTimer, hasProtocol, isAnimatorLike, resolvePath } from "./utils";
  7. export const DEFAULT_CODECS = {
  8. mp4: "libx264",
  9. webm: "libvpx-vp9",
  10. };
  11. /**
  12. * A recorder that captures the screen and creates a video or audio file
  13. * @example
  14. import Recorder, { OnRequestCapture } from "@scenjs/recorder";
  15. import Scene from "scenejs";
  16. const scene = new Scene();
  17. const recorder = new Recorder();
  18. recorder.setAnimator(scene);
  19. recorder.setCapturing("png", (e: OnRequestCapture) => {
  20. scene.setTime(e.time, true);
  21. // html to image
  22. return htmlToImage(element);
  23. });
  24. recorder.record().then(data => {
  25. const url = URL.createObjectURL(new Blob(
  26. [data.buffer],
  27. { type: 'video/mp4' },
  28. ));
  29. video.setAttribute("src", url);
  30. recorder.destroy();
  31. });
  32. */
  33. export class Recorder extends EventEmitter<{
  34. captureStart: OnCaptureStart;
  35. capture: OnCapture;
  36. captureEnd: {};
  37. processVideoStart: Required<RenderVideoOptions>;
  38. processVideo: OnProcess;
  39. processVideoEnd: {};
  40. processAudioStart: OnProcessAudioStart;
  41. processAudio: OnProcess;
  42. processAudioEnd: {};
  43. }> {
  44. protected _animator!: AnimatorLike;
  45. protected _imageType!: "jpeg" | "png";
  46. protected _ffmpeg!: FFmpeg;
  47. protected _ready!: Promise<void>;
  48. protected _hasMedia!: boolean;
  49. protected _fetchFile: (data: FileType) => Promise<Uint8Array | null> = fetchFile;
  50. protected _capturing!: (e: OnRequestCapture) => Promise<FileType> | FileType;
  51. public recordState: "initial" | "loading" | "capture" | "process" = "initial";
  52. /**
  53. *
  54. */
  55. constructor(protected _options: RecorderOptions = {}) {
  56. super();
  57. }
  58. /**
  59. * Set up a function to import files. Defaults to fetchData from `@ffmpeg/ffmpeg`
  60. * @sort 1
  61. */
  62. public setFetchFile(fetchFile: (data: FileType) => Promise<Uint8Array | null>) {
  63. this._fetchFile = fetchFile;
  64. }
  65. /**
  66. * Set the function to get the image to be captured per frame.
  67. * @sort 1
  68. * @param - image extension of the file
  69. * @param - A function that returns the image to be captured per frame.
  70. */
  71. public setCapturing(
  72. imageType: "jpeg" | "png",
  73. capturing: (e: OnRequestCapture) => Promise<FileType> | FileType,
  74. ) {
  75. this._imageType = imageType;
  76. this._capturing = capturing;
  77. }
  78. /**
  79. * Set the animator to record.
  80. * @sort 1
  81. */
  82. public setAnimator(animator: AnimatorLike | Partial<AnimatorOptions>) {
  83. this._animator
  84. = isAnimatorLike(animator)
  85. ? animator
  86. : new Animator(animator);
  87. }
  88. /**
  89. * Get the result of audio processing.
  90. * @sort 1
  91. */
  92. public getAudioFile(): Uint8Array {
  93. return this._ffmpeg.FS("readFile", "merge.mp3");
  94. }
  95. /**
  96. * Start audio processing.
  97. * @sort 1
  98. * @param mediaInfo - media info
  99. * @param options - media info options
  100. * @returns {$ts:Promise<Uint8Array>}
  101. */
  102. public async recordMedia(mediaInfo: MediaSceneInfo, options?: RenderMediaInfoOptions) {
  103. let length = 0;
  104. const medias = mediaInfo.medias;
  105. const duration = mediaInfo.duration;
  106. if (!duration || !medias) {
  107. return;
  108. }
  109. const ffmpeg = await this.init();
  110. await medias.reduce(async (pipe, media) => {
  111. await pipe;
  112. const url = media.url;
  113. const seek = media.seek;
  114. const delay = media.delay;
  115. const playSpeed = media.playSpeed;
  116. const volume = media.volume;
  117. const path = hasProtocol(url) ? url : resolvePath(options?.inputPath ?? "", url);
  118. const [startTime, endTime] = seek;
  119. const fileName = path.match(/[^/]+$/g)?.[0] ?? path;
  120. await this.writeFile(fileName, path);
  121. await ffmpeg.run(
  122. "-ss", `${startTime}`,
  123. "-to", `${endTime}`,
  124. "-i", fileName,
  125. "-filter:a", `adelay=${delay * playSpeed * 1000}|${delay * playSpeed * 1000},atempo=${playSpeed},volume=${volume}`,
  126. `audio${length++}.mp3`,
  127. );
  128. }, Promise.resolve());
  129. if (!length) {
  130. return;
  131. }
  132. const files = ffmpeg.FS("readdir", "./");
  133. const audios = files.filter(fileName => fileName.match(/audio[\d]+.mp3/));
  134. const audiosLength = audios.length;
  135. if (!audiosLength) {
  136. return;
  137. }
  138. const inputOption: string[] = [];
  139. const timer = createTimer();
  140. /**
  141. * The event is fired when audio process starts.
  142. * @memberof Recorder
  143. * @event processAudioStart
  144. * @param {Recorder.OnProcessAudioStart} - Parameters for the `processAudioStart` event
  145. */
  146. this.emit("processAudioStart", {
  147. audiosLength,
  148. });
  149. audios.forEach(fileName => {
  150. inputOption.push("-i", fileName);
  151. });
  152. ffmpeg.setProgress(e => {
  153. const ratio = e.ratio;
  154. const {
  155. currentTime,
  156. expectedTime,
  157. } = timer.getCurrentInfo(e.ratio);
  158. /**
  159. * The event is fired when audio processing is in progress.
  160. * @memberof Recorder
  161. * @event processAudio
  162. * @param {Recorder.OnProcess} - Parameters for the `processAudio` event
  163. */
  164. this.emit("processAudio", {
  165. currentProcessingTime: currentTime,
  166. expectedProcessingTime: expectedTime,
  167. ratio,
  168. });
  169. });
  170. await ffmpeg.run(
  171. ...inputOption,
  172. "-filter_complex", `amix=inputs=${audiosLength}:duration=longest:dropout_transition=1000,volume=${audiosLength}`,
  173. "merge.mp3",
  174. );
  175. /**
  176. * The event is fired when audio process ends.
  177. * @memberof Recorder
  178. * @event processAudioEnd
  179. */
  180. this.emit("processAudioEnd");
  181. if (ffmpeg.FS("readdir", "./").indexOf("merge.mp3") >= 0) {
  182. this._hasMedia = true;
  183. return this.getAudioFile();
  184. }
  185. }
  186. /**
  187. * Start capturing and video processing.
  188. * @sort 1
  189. * @param options - record options
  190. * @returns {$ts:Promise<Uint8Array>}
  191. */
  192. public async record(options: RenderVideoOptions & RecordInfoOptions = {}) {
  193. const recordInfo = this.getRecordInfo(options);
  194. const rootStartFrame = recordInfo.startFrame;
  195. const rootEndFrame = recordInfo.endFrame;
  196. const imageType = this._imageType;
  197. const totalFrame = rootEndFrame - rootStartFrame + 1;
  198. const fps = options.fps || 60;
  199. let frameCount = 0;
  200. this.recordState = "loading";
  201. await this.init();
  202. const timer = createTimer();
  203. /**
  204. * The event is fired when capture starts.
  205. * @memberof Recorder
  206. * @event captureStart
  207. * @param {Recorder.OnCaptureStart} - Parameters for the `captureStart` event
  208. */
  209. this.emit("captureStart", {
  210. startFame: rootStartFrame,
  211. endFrame: rootEndFrame,
  212. startTime: recordInfo.startTime,
  213. endTime: recordInfo.endTime,
  214. duration: recordInfo.duation,
  215. multi: options.multi || 1,
  216. imageType,
  217. fps,
  218. });
  219. this.recordState = "capture";
  220. await Promise.all(recordInfo.loops.map((loop, workerIndex) => {
  221. let pipe = Promise.resolve();
  222. const startFrame = loop.startFrame;
  223. const endFrame = loop.endFrame;
  224. for (let i = startFrame; i <= endFrame; ++i) {
  225. const callback = ((currentFrame: number) => {
  226. return async () => {
  227. const time = currentFrame / fps;
  228. const data = await this._capturing({
  229. workerIndex,
  230. frame: currentFrame,
  231. time,
  232. });
  233. await this.writeFile(`frame${currentFrame - rootStartFrame}.${imageType}`, data);
  234. ++frameCount;
  235. const ratio = frameCount / totalFrame;
  236. const {
  237. currentTime: currentCapturingTime,
  238. expectedTime: expectedCapturingTime,
  239. } = timer.getCurrentInfo(ratio);
  240. /**
  241. * The event is fired when frame capturing is in progress.
  242. * @memberof Recorder
  243. * @event capture
  244. * @param {Recorder.OnCapture} - Parameters for the `capture` event
  245. */
  246. this.emit("capture", {
  247. ratio,
  248. frameCount,
  249. totalFrame,
  250. frameInfo: {
  251. frame: currentFrame,
  252. time,
  253. },
  254. currentCapturingTime,
  255. expectedCapturingTime,
  256. });
  257. };
  258. })(i);
  259. pipe = pipe.then(callback);
  260. }
  261. return pipe;
  262. }));
  263. /**
  264. * The event is fired when capture ends.
  265. * @memberof Recorder
  266. * @event captureEnd
  267. */
  268. this.emit("captureEnd");
  269. return await this.renderVideo({
  270. ...options,
  271. duration: recordInfo.duation,
  272. });
  273. }
  274. /**
  275. * Get the information to be recorded through options.
  276. * @sort 1
  277. */
  278. public getRecordInfo(options: RecordInfoOptions) {
  279. const animator = this._animator;
  280. const inputIteration = options.iteration;
  281. const inputDuration = options.duration || 0;
  282. const inputStartTime = options.startTime || 0;
  283. const inputFPS = options.fps || 60;
  284. const inputMulti = options.multi || 1;
  285. const sceneIterationCount = inputIteration || animator.getIterationCount();
  286. const sceneDelay = animator.getDelay();
  287. const playSpeed = animator.getPlaySpeed();
  288. const duration = animator.getDuration();
  289. let iterationCount = 0;
  290. if (sceneIterationCount === "infinite") {
  291. iterationCount = inputIteration || 1;
  292. } else {
  293. iterationCount = inputIteration || sceneIterationCount;
  294. }
  295. const totalDuration = sceneDelay + duration * (iterationCount);
  296. const endTime = inputDuration > 0
  297. ? Math.min(inputStartTime + inputDuration, totalDuration)
  298. : totalDuration;
  299. const startTime = Math.min(inputStartTime, endTime);
  300. const startFrame = Math.floor(startTime * inputFPS / playSpeed);
  301. const endFrame = Math.ceil(endTime * inputFPS / playSpeed);
  302. const dist = Math.ceil((endFrame - startFrame) / (inputMulti || 1));
  303. const loops: Array<{
  304. startFrame: number;
  305. endFrame: number;
  306. }> = [];
  307. for (let i = 0; i < inputMulti; ++i) {
  308. loops.push({
  309. startFrame: startFrame + dist * i + (i === 0 ? 0 : 1),
  310. endFrame: startFrame + dist * (i + 1),
  311. });
  312. }
  313. return {
  314. duation: (endTime - startTime) / playSpeed,
  315. loops,
  316. iterationCount,
  317. startTime,
  318. endTime,
  319. startFrame,
  320. endFrame,
  321. }
  322. }
  323. public async init() {
  324. this._ffmpeg = this._ffmpeg || createFFmpeg({ log: this._options.log });
  325. const ffmpeg = this._ffmpeg;
  326. if (!this._ready) {
  327. this._ready = ffmpeg.load();
  328. }
  329. await this._ready;
  330. return ffmpeg;
  331. }
  332. public async writeFile(fileName: string, file: string | Buffer | File | Blob) {
  333. await this.init();
  334. const data = await this._fetchFile(file);
  335. if (!data) {
  336. return;
  337. }
  338. this._ffmpeg.FS("writeFile", fileName, data);
  339. }
  340. public async renderVideo(options: RenderVideoOptions) {
  341. const {
  342. ext = "mp4",
  343. fps = 60,
  344. codec,
  345. duration,
  346. bitrate: bitrateOption,
  347. cpuUsed,
  348. } = options;
  349. const hasMedia = this._hasMedia;
  350. const parsedCodec = codec || DEFAULT_CODECS[ext || "mp4"] || DEFAULT_CODECS.mp4;
  351. const bitrate = bitrateOption || "4096k";
  352. const inputOption = [
  353. "-i", `frame%d.${this._imageType}`,
  354. ];
  355. const audioOutputOpion: string[] = [];
  356. const outputOption = [
  357. `-cpu-used`, `${cpuUsed || 8}`,
  358. "-pix_fmt", "yuva420p",
  359. ];
  360. if (ext === "webm") {
  361. outputOption.push(
  362. "-row-mt", "1",
  363. );
  364. }
  365. if (hasMedia) {
  366. inputOption.push(
  367. "-i", "merge.mp3",
  368. );
  369. audioOutputOpion.push(
  370. "-acodec", "aac",
  371. // audio bitrate
  372. '-b:a', "128k",
  373. // audio channels
  374. "-ac", "2",
  375. );
  376. }
  377. const ffmpeg = await this.init();
  378. this.recordState = "process";
  379. const timer = createTimer();
  380. /**
  381. * The event is fired when process video starts.
  382. * @memberof Recorder
  383. * @event processVideoStart
  384. * @param {Recorder.OnProcessVideoStart} - Parameters for the `processVideoStart` event
  385. */
  386. this.emit("processVideoStart", {
  387. ext,
  388. fps,
  389. codec: parsedCodec,
  390. duration,
  391. bitrate,
  392. cpuUsed,
  393. });
  394. ffmpeg.setProgress(e => {
  395. const ratio = e.ratio;
  396. const {
  397. currentTime,
  398. expectedTime,
  399. } = timer.getCurrentInfo(e.ratio);
  400. /**
  401. * The event is fired when frame video processing is in progress.
  402. * @memberof Recorder
  403. * @event processVideo
  404. * @param {Recorder.OnProcess} - Parameters for the `processVideo` event
  405. */
  406. this.emit("processVideo", {
  407. currentProcessingTime: currentTime,
  408. expectedProcessingTime: expectedTime,
  409. ratio,
  410. });
  411. });
  412. await ffmpeg!.run(
  413. `-r`, `${fps}`,
  414. ...inputOption,
  415. ...audioOutputOpion,
  416. `-c:v`, parsedCodec,
  417. `-loop`, `1`,
  418. `-t`, `${duration}`,
  419. "-y",
  420. `-b:v`, bitrate,
  421. ...outputOption,
  422. `output.${ext}`,
  423. );
  424. /**
  425. * The event is fired when process video ends
  426. * @memberof Recorder
  427. * @event processVideoEnd
  428. */
  429. this.emit("processVideoEnd");
  430. this.recordState = "initial";
  431. return ffmpeg!.FS('readFile', `output.${ext}`);
  432. }
  433. /**
  434. * Quit ffmpeg.
  435. * @sort 1
  436. */
  437. public exit() {
  438. try {
  439. this.recordState = "initial";
  440. this._ready = null;
  441. this._ffmpeg?.exit();
  442. } catch (e) {
  443. }
  444. this._ffmpeg = null;
  445. }
  446. /**
  447. * Remove the recorder and ffmpeg instance.
  448. * @sort 1
  449. */
  450. public destroy() {
  451. this.off();
  452. this.exit();
  453. }
  454. }