├── .editorconfig ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── ---feature-request.md │ ├── --bug-report.md │ └── -questions---help.md ├── .gitignore ├── CHANGELOG.md ├── README.md ├── demo ├── images │ └── screenshot.png └── index.html ├── jsdoc.json ├── lerna.json ├── netlify.toml ├── package.json ├── packages ├── recorder-react-demo │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── clapperboard.png │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.css │ │ ├── App.test.tsx │ │ ├── App.tsx │ │ ├── Card │ │ │ └── Card.tsx │ │ ├── Clapper1 │ │ │ └── Clapper1.tsx │ │ ├── Clapper2 │ │ │ └── Clapper2.tsx │ │ ├── Gallery.tsx │ │ ├── GalleryItem │ │ │ ├── GalleryItem.tsx │ │ │ └── Progress.tsx │ │ ├── HTMLRecorder.ts │ │ ├── MotionEffect │ │ │ └── MotionEffect.tsx │ │ ├── RenderModal.tsx │ │ ├── SmokeTransition │ │ │ └── SmokeTransition.tsx │ │ ├── SquareTransition │ │ │ └── SquareTransition.tsx │ │ ├── global.d.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── logo.svg │ │ ├── react-app-env.d.ts │ │ ├── reportWebVitals.ts │ │ ├── setupProxy.js │ │ └── setupTests.ts │ ├── test.html │ └── tsconfig.json ├── recorder │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── Recorder.ts │ │ ├── index.cjs.ts │ │ ├── index.ts │ │ ├── index.umd.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── test.js │ ├── test2.js │ ├── tsconfig.declaration.json │ └── tsconfig.json └── render │ ├── .editorconfig │ ├── .gitignore │ ├── .npmignore │ ├── CHANGELOG.md │ ├── README.md │ ├── examples │ ├── background.mp3 │ └── index.html │ ├── index.js │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── BinaryRecorder.ts │ ├── Logger.ts │ ├── RenderRecorder.ts │ ├── child.ts │ ├── index.ts │ ├── render.ts │ ├── types.ts │ └── utils.ts │ ├── test │ ├── test.html │ ├── test.js │ └── test2.html │ ├── tsconfig.declaration.json │ ├── tsconfig.json │ └── tsconfig.test.json ├── static └── scripts │ └── custom.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [{*.js,*.ts}] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [{package.json,.travis.yml}] 14 | indent_style = space 15 | indent_size = 4 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: daybrush # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/--bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41BBug report" 3 | about: Create a report to help us improve 4 | 5 | --- 6 | ## Environments 7 | * OS name: 8 | * Scene.js Component Version: 9 | * Component Name (Render, Recorder): 10 | * Component version: 11 | * Testable Address(optional): 12 | 13 | ## Description 14 | 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-questions---help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "❓Questions & Help" 3 | about: I have questions or need help with Scene.js Render or Recorder 4 | 5 | --- 6 | 7 | 8 | ## Environments 9 | * OS name: 10 | * Scene.js Component Version: 11 | * Component Name (Render, Recorder): 12 | * Component version: 13 | * Testable Address(optional): 14 | 15 | ## Description 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.DS_Store 3 | .DS_Store 4 | doc/ 5 | dist/ 6 | packages/*/dist 7 | demo/dist/ 8 | release/ 9 | npm-debug.log* 10 | coverage/ 11 | jsdoc/ 12 | doc/ 13 | outjs/ 14 | declaration/ 15 | build/ 16 | .vscode/ 17 | rollup-plugin-visualizer/ 18 | statistics/ 19 | .scene_cache 20 | .scene_ch 21 | *.mp4 22 | *.webm 23 | ffmpeg 24 | scene.js -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.16.0](https://github.com/daybrush/scenejs-render/compare/0.15.0...0.16.0) (2023-06-19) 7 | ### :sparkles: Packages 8 | * `@scenejs/recorder` 0.15.0 9 | * `@scenejs/render` 0.16.0 10 | 11 | 12 | ### :rocket: New Features 13 | 14 | * `@scenejs/render` 15 | * add logger, noLog, created options #30 ([fd7f5cb](https://github.com/daybrush/scenejs-render/commit/fd7f5cb5948d025193676f44a62b72dca7ab41bd)) 16 | 17 | 18 | ### :bug: Bug Fix 19 | 20 | * `@scenejs/render` 21 | * fix default value #27 ([96b42f7](https://github.com/daybrush/scenejs-render/commit/96b42f70d9bc72195491080c4c5ae3e8000e1a7d)) 22 | * Other 23 | * fix build script ([d6559d1](https://github.com/daybrush/scenejs-render/commit/d6559d1dc18934244ac5d467903e46b67376ba14)) 24 | * fix netlify config ([9054170](https://github.com/daybrush/scenejs-render/commit/9054170375c8dfa4c93c77a6784747463f723b50)) 25 | 26 | 27 | ### :memo: Documentation 28 | 29 | * `@scenejs/render` 30 | * fix README ([287bdda](https://github.com/daybrush/scenejs-render/commit/287bddabc010854c9ad5f3d8fbe294a70f11b2e3)) 31 | * fix README ([be8c291](https://github.com/daybrush/scenejs-render/commit/be8c291f5d0de37d3975afad79ea85679cc0c17d)) 32 | * fix README for node 18 #29 ([ee14b58](https://github.com/daybrush/scenejs-render/commit/ee14b581d940fe98e921f41eb9d41cd55d746c21)) 33 | * fix types ([ebc4f62](https://github.com/daybrush/scenejs-render/commit/ebc4f627253916779ed33357f2aa763473bea469)) 34 | 35 | 36 | ### :mega: Other 37 | 38 | * `@scenejs/render` 39 | * bump render version ([d5bf670](https://github.com/daybrush/scenejs-render/commit/d5bf6702d4508a80f2c67a3d87bad8fdc7a874ae)) 40 | * update version ([d1e5d49](https://github.com/daybrush/scenejs-render/commit/d1e5d49e204757f8a35e1c092c92962dc59402ff)) 41 | 42 | 43 | 44 | ## 0.15.0 (2023-01-19) 45 | ### :sparkles: Packages 46 | * `@scenejs/recorder` 0.15.0 47 | * `@scenejs/render` 0.15.0 48 | 49 | 50 | ### :rocket: New Features 51 | 52 | * All 53 | * add recorder ([d84e97a](https://github.com/daybrush/scenejs-render/commit/d84e97a737a08f1f0b39a6cc49b68ad6bfc9b7e6)) 54 | * add recorder demo ([4e64808](https://github.com/daybrush/scenejs-render/commit/4e648080b4dd6d13232ad1fe1b388073289b510a)) 55 | * add setAnimator options ([048102b](https://github.com/daybrush/scenejs-render/commit/048102b1d2f220367d4262e7aaeffe42141858a1)) 56 | * setFetchFile ([4a394d8](https://github.com/daybrush/scenejs-render/commit/4a394d86fcdda6bf1d76492a2b40db900c8fd374)) 57 | * `@scenejs/render` 58 | * add ffmpegPath option ([fe3f91b](https://github.com/daybrush/scenejs-render/commit/fe3f91bc11fa99d386f8f27cf2bf15d2f09e34a1)) 59 | * add render-core ([94bf5ec](https://github.com/daybrush/scenejs-render/commit/94bf5ec60011d384653129560bf8d4315825b4be)) 60 | * use recorder ([b6a9f6d](https://github.com/daybrush/scenejs-render/commit/b6a9f6d919d14563fdebfcbe844dea507c064b7d)) 61 | * `@scenejs/recorder` 62 | * add recorder events ([a01b5b6](https://github.com/daybrush/scenejs-render/commit/a01b5b6ce0b72c5bfdbb5eee5e6b4448b46474c8)) 63 | * add state ([3fbe8e6](https://github.com/daybrush/scenejs-render/commit/3fbe8e6ac868d22b7e60fc6f78531aa6fc464020)) 64 | * Other 65 | * add cache option ([445e2d2](https://github.com/daybrush/scenejs-render/commit/445e2d26e32438f885e3054937d8bd46050595c0)) 66 | * add cacheFolder arg #19 ([2491c54](https://github.com/daybrush/scenejs-render/commit/2491c54ab3eb24e317e3104e90ea5923add14727)) 67 | * add codec option ([215c559](https://github.com/daybrush/scenejs-render/commit/215c559ee96632e41619aac12db26fb08538b146)) 68 | * add ffmpegPath ([c689b10](https://github.com/daybrush/scenejs-render/commit/c689b108dab5a737bba26e4e9455483eb744af73)) 69 | * add imageType, alpha ([3ce5d94](https://github.com/daybrush/scenejs-render/commit/3ce5d94b8dcbf4f097cbf54491f38d1e33a53180)) 70 | * add input url address ([23b3b8d](https://github.com/daybrush/scenejs-render/commit/23b3b8d7c1d2ac7d1205cebbbebf0e7fc133ad7e)) 71 | * add multiprocess option ([7994e00](https://github.com/daybrush/scenejs-render/commit/7994e009a5245c821f73689d695b37a9dceb6e68)) 72 | * add referer option ([4426374](https://github.com/daybrush/scenejs-render/commit/442637486dedd44e47ff6d18ec286d5ee4b34776)) 73 | * add Renderer module ([e0c9c8c](https://github.com/daybrush/scenejs-render/commit/e0c9c8cff5f3d7317303640656950829e2d974fe)) 74 | * add Renederer class ([d6af158](https://github.com/daybrush/scenejs-render/commit/d6af1589637742186bafab057c5e70d207288b67)) 75 | * add scale option ([a0974b4](https://github.com/daybrush/scenejs-render/commit/a0974b41e64bb67432fc222488290c3d5ed2454d)) 76 | * add useragent ([fccb175](https://github.com/daybrush/scenejs-render/commit/fccb175d8461f7cbf8e3c7c646cfcdddfe58c963)) 77 | * fix duration and add iteration option ([3b6fb95](https://github.com/daybrush/scenejs-render/commit/3b6fb95719469ecaf6a9da3c40f625ce9003958e)) 78 | * sendMessage for process ([a798cdc](https://github.com/daybrush/scenejs-render/commit/a798cdc490e4b98185d9db74589cab490bae416e)) 79 | * support mediascene for mp3 ([fb353fc](https://github.com/daybrush/scenejs-render/commit/fb353fc4ccfe50f8d15eb708d57db0d4918910ea)) 80 | * support webm code and add bitrate option ([ec7b638](https://github.com/daybrush/scenejs-render/commit/ec7b6382640b1ed6d61ef478fb01d2c8495257a2)) 81 | * update modules ([865bf13](https://github.com/daybrush/scenejs-render/commit/865bf139562702394b69b209358a55291c1e82ed)) 82 | 83 | 84 | ### :bug: Bug Fix 85 | 86 | * `@scenejs/recorder` 87 | * fix amix volume #24 ([944b55f](https://github.com/daybrush/scenejs-render/commit/944b55fe32da9ac3149347aa5d7e043e6c19386e)) 88 | * fix Recorder accessor ([b9a735a](https://github.com/daybrush/scenejs-render/commit/b9a735a338efe9002f6f68fae04a3db97f03a3bf)) 89 | * Other 90 | * add FFMPEG_PATH env ([6e019a4](https://github.com/daybrush/scenejs-render/commit/6e019a418951786c8b639b52bce0664b38ec9133)) 91 | * add types ([4108055](https://github.com/daybrush/scenejs-render/commit/41080552c0335996f31b2e7c6186a1ab21021e65)) 92 | * change multiprocess to multi ([5c98211](https://github.com/daybrush/scenejs-render/commit/5c9821183ba05698e477b031833318931ab4f938)) 93 | * convert TypeScript ([7a836e8](https://github.com/daybrush/scenejs-render/commit/7a836e8abfeac3a1826d98ff55fc532f82c122e3)) 94 | * fix audio channel count ([e06d86d](https://github.com/daybrush/scenejs-render/commit/e06d86d9413dbd6a179e590e518226f3135737e0)) 95 | * fix build config ([35350c7](https://github.com/daybrush/scenejs-render/commit/35350c7bc5d5c38299e5b0c87e42f70856f67c8e)) 96 | * fix default resolution ([67b1a7f](https://github.com/daybrush/scenejs-render/commit/67b1a7f923b01d1898f9a1f503d4b3936c78e701)) 97 | * fix exit code and split module ([a050075](https://github.com/daybrush/scenejs-render/commit/a05007549013c2f887693ed13f66083edeac2f0b)) 98 | * fix frame number for startTime #15 ([af04ac4](https://github.com/daybrush/scenejs-render/commit/af04ac482c275bc4e5e9e6fe3e006adc9d6d57b6)) 99 | * fix lerna options ([4b3b8fd](https://github.com/daybrush/scenejs-render/commit/4b3b8fd89203f30fc63bfe524cbed971b18d8063)) 100 | * fix media info ([8fdbb3c](https://github.com/daybrush/scenejs-render/commit/8fdbb3ca9edb4f9b30753029db760f5e69b37b82)) 101 | * Fix media url ([0d651e9](https://github.com/daybrush/scenejs-render/commit/0d651e9c47286b9510d0bacc55868693211b6c48)) 102 | * fix no sandbox issue #9 ([534c596](https://github.com/daybrush/scenejs-render/commit/534c596ecd262174b98cc583b817df4cab03519c)) 103 | * fix openPage bug ([97b6a22](https://github.com/daybrush/scenejs-render/commit/97b6a22188c39984cbabc49f97ae0922f3808edc)) 104 | * fix package and add examples ([eab6403](https://github.com/daybrush/scenejs-render/commit/eab64035935da53be1329bf1a9810f4143014d8a)) 105 | * fix README ([b27886e](https://github.com/daybrush/scenejs-render/commit/b27886e753bffaa463df1692cfce7fa2afec2a1f)) 106 | * fix README ([dbc4c8e](https://github.com/daybrush/scenejs-render/commit/dbc4c8e5cbe1fa2c732cf6fe742aa6e3d9cd10fc)) 107 | * fix README ([08f4e66](https://github.com/daybrush/scenejs-render/commit/08f4e664918556740487a43eed8e326018740115)) 108 | * fix README ([e6f1ec8](https://github.com/daybrush/scenejs-render/commit/e6f1ec814abfc400383933e2031fa421d55a93d4)) 109 | * fix README ([db7ee75](https://github.com/daybrush/scenejs-render/commit/db7ee757824be1f0f5542e6c855da68696434f9e)) 110 | * fix recordMedia for updated @scenejs/media ([1a64901](https://github.com/daybrush/scenejs-render/commit/1a64901fcebffc85d15ad84d5a22bf07d0ea03a4)) 111 | * fix scale ([c5e551f](https://github.com/daybrush/scenejs-render/commit/c5e551fb6fd63a2cba427976f153ba6be1e44e97)) 112 | * fix seeking timing issue #14 ([bd315dc](https://github.com/daybrush/scenejs-render/commit/bd315dcd17621396ca8ec6112b244e002ea8aaf0)) 113 | * fix setTime loop ([bc2c46f](https://github.com/daybrush/scenejs-render/commit/bc2c46ff4ee6a3f22c0ee78c72d9db17d78e596a)) 114 | * fix type ([33e546c](https://github.com/daybrush/scenejs-render/commit/33e546c3da0abde0294105b9a7be45633f1a3c1c)) 115 | * fix types ([d288e38](https://github.com/daybrush/scenejs-render/commit/d288e38fa410c7ce84dba329cf55f76454a28599)) 116 | * split modules & functions ([11c0de9](https://github.com/daybrush/scenejs-render/commit/11c0de9e66f6f315729dc8dec5086b6fc7602f00)) 117 | * update packages ([e3dd183](https://github.com/daybrush/scenejs-render/commit/e3dd183435c5d5a9ac6d2b326a860671a78a7ccb)) 118 | 119 | 120 | ### :memo: Documentation 121 | 122 | * All 123 | * fix README ([25e0795](https://github.com/daybrush/scenejs-render/commit/25e0795c00f1d87bdf6c6625e2f1856052b6f9b6)) 124 | * fix README ([9dbae78](https://github.com/daybrush/scenejs-render/commit/9dbae78daaecc0ae08948f56ca44efd83fd911c7)) 125 | * fix README ([9d368bf](https://github.com/daybrush/scenejs-render/commit/9d368bff00c679181d5a902c3daeb818a257911d)) 126 | * `@scenejs/recorder` 127 | * add docs ([5d27ee9](https://github.com/daybrush/scenejs-render/commit/5d27ee9604af7c84ad824e1a10f9219ac7222989)) 128 | * `@scenejs/render` 129 | * fix README ([3352c1c](https://github.com/daybrush/scenejs-render/commit/3352c1cd679d296fdaa2e3878ae99ef51a295f58)) 130 | * move paths ([aa10170](https://github.com/daybrush/scenejs-render/commit/aa10170927c415edfa2f0f519a2f71c2449355fc)) 131 | 132 | 133 | ### :mega: Other 134 | 135 | * All 136 | * publish packages ([551ae6d](https://github.com/daybrush/scenejs-render/commit/551ae6d9b65c77dcd85307fc8c8eab9ae0129703)) 137 | * publish packages ([6278907](https://github.com/daybrush/scenejs-render/commit/6278907961dea2fbce948d83e437c2abcd313f79)) 138 | * publish packages ([2eb076e](https://github.com/daybrush/scenejs-render/commit/2eb076ec637830a8a6b226d4999d7fd2b6c41146)) 139 | * Other 140 | * remove ffmpeg ([889363a](https://github.com/daybrush/scenejs-render/commit/889363ae3de7419b2f6dd6213a1c1680db777561)) 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

Scene.js Render

4 |

5 | npm version 6 | 7 | 8 |

9 | 10 | 11 |

🎬 Make a movie of CSS animation through Scene.js

12 | 13 |

Official Site  /  API  /  Scene.js  /  Main Project

14 |
15 | 16 |

17 | screenshot 18 |

19 | 20 | 21 | ## 🎬 How to use 22 | #### Node 23 | It reads a file from Node through a command and records (capture and video process) to create a video (or audio) file. 24 | 25 | 26 | See [**`@scenejs/render` page**](https://github.com/daybrush/scenejs-render/tree/master/packages/render) 27 | 28 | ```bash 29 | $ npm install @scenejs/render 30 | ``` 31 | 32 | ```bash 33 | $ npx @scenejs/render -i index.html 34 | ``` 35 | #### Programmatically 36 | ```js 37 | const render = require("@scenejs/render"); 38 | const options = { 39 | input: "", 40 | name: "scene", 41 | mediaScene: "mediaScene", 42 | output: "", 43 | ffmpegPath: "/usr/bin/ffmpeg", 44 | width: width, 45 | height: height, 46 | fps:30, 47 | imageType : "jpeg", 48 | multi: 2, 49 | buffer:true, 50 | scale: 1, 51 | cacheFolder: "cacheFolderPath", 52 | }; 53 | await render.render(options); 54 | ``` 55 | 56 | Make sure that the scene variable is declared and accessible in the global(window) scope or else a timeout will occur. If scene variable is declared using another name, make sure to update the "name" parameter in the option with the name of your scene variable 57 | 58 | If you are running multiple instances of the render function simultaneously, make sure to specify unique cachefolder paths or the frames will get mixed up. 59 | 60 | #### Browser 61 | Through the module, you can record by specifying the capture method manually and create a file manually through the data. 62 | 63 | 64 | See [**`@scenejs/recorder` page**](https://github.com/daybrush/scenejs-render/tree/master/packages/recorder) 65 | 66 | ```bash 67 | $ npm install @scenejs/recorder 68 | ``` 69 | 70 | 71 | ```js 72 | import Recorder from "@scenjs/recorder"; 73 | import Scene, { OnAnimate } from "scenejs"; 74 | 75 | const scene = new Scene(); 76 | const recorder = new Recorder(); 77 | 78 | recorder.setAnimator(scene); 79 | recorder.setCapturing("png", (e: OnAnimate) => { 80 | // html to image 81 | }); 82 | 83 | recorder.record().then(data => { 84 | const url = URL.createObjectURL(new Blob( 85 | [data.buffer], 86 | { type: 'video/mp4' }, 87 | )); 88 | 89 | video.setAttribute("src", url); 90 | }); 91 | ``` 92 | 93 | 94 | Since `@ffmpeg/ffmpeg` is used, please refer to the document https://github.com/ffmpegwasm/ffmpeg.wasm. 95 | 96 | Or, using a script tag in the browser (only works in some browsers, see list below): 97 | 98 | > SharedArrayBuffer is only available to pages that are [cross-origin isolated](https://developer.chrome.com/blog/enabling-shared-array-buffer/#cross-origin-isolation). So you need to host [your own server](https://github.com/ffmpegwasm/ffmpegwasm.github.io/blob/main/server/server.js) with `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Opener-Policy: same-origin` headers to use ffmpeg.wasm. 99 | 100 | 101 | > Only browsers with SharedArrayBuffer support can use ffmpeg.wasm, you can check [HERE](https://caniuse.com/sharedarraybuffer) for the complete list. 102 | 103 | 104 | 105 | 106 | 107 | ## ⭐️ Show Your Support 108 | Please give a ⭐️ if this project helped you! 109 | 110 | 111 | ## 👏 Contributing 112 | 113 | If you have any questions or requests or want to contribute to `scenejs-render` or other packages, please write the [issue](https://github.com/daybrush/scenejs-render/issues) or give me a Pull Request freely. 114 | 115 | 116 | ### Code Contributors 117 | 118 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 119 | 120 | 121 | 122 | 123 | 124 | 125 | ## Sponsors 126 |

127 | 128 | 129 | 130 |

131 | 132 | 133 | ## 🐞 Bug Report 134 | 135 | If you find a bug, please report to us opening a new [Issue](https://github.com/daybrush/scenejs-render/issues) on GitHub. 136 | 137 | 138 | 139 | ## 📝 License 140 | 141 | This project is [MIT](https://github.com/daybrush/scenejs-render/blob/master/LICENSE) licensed. 142 | 143 | ``` 144 | MIT License 145 | 146 | Copyright (c) 2016 Daybrush 147 | 148 | Permission is hereby granted, free of charge, to any person obtaining a copy 149 | of this software and associated documentation files (the "Software"), to deal 150 | in the Software without restriction, including without limitation the rights 151 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 152 | copies of the Software, and to permit persons to whom the Software is 153 | furnished to do so, subject to the following conditions: 154 | 155 | The above copyright notice and this permission notice shall be included in all 156 | copies or substantial portions of the Software. 157 | 158 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 159 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 160 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 161 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 162 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 163 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 164 | SOFTWARE. 165 | ``` 166 | -------------------------------------------------------------------------------- /demo/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/demo/images/screenshot.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [], 3 | "recurseDepth": 10, 4 | "opts": { 5 | "template": "./node_modules/daybrush-jsdoc-template", 6 | "destination": "./doc/" 7 | }, 8 | "source": { 9 | "include": [ 10 | "./packages/render/src/", 11 | "./packages/recorder/src/", 12 | "./README.md", 13 | "./node_modules/@scena/event-emitter/src/" 14 | ], 15 | "includePattern": "(.+\\.js(doc|x)?|.+\\.ts(doc|x)?)$", 16 | "excludePattern": "(^|\\/|\\\\)_" 17 | }, 18 | "sourceType": "module", 19 | "tags": { 20 | "allowUnknownTags": true, 21 | "dictionaries": [ 22 | "jsdoc", 23 | "closure" 24 | ] 25 | }, 26 | "templates": { 27 | "cleverLinks": false, 28 | "monospaceLinks": false, 29 | "default": { 30 | "staticFiles": { 31 | "include": [ 32 | "./static" 33 | ] 34 | } 35 | } 36 | }, 37 | "linkMap": { 38 | "Animator": "https://daybrush.com/scenejs/release/latest/doc/Animator.html", 39 | "AnimatorOptions": "https://daybrush.com/scenejs/release/latest/doc/global.html#AnimatorOptions", 40 | "IObject": "http://daybrush.com/utils/release/latest/doc/global.html#ObjectInterface" 41 | }, 42 | "docdash": { 43 | "menu": { 44 | "Github repo": { 45 | "href": "https://github.com/daybrush/scenejs-render", 46 | "target": "_blank", 47 | "class": "menu-item", 48 | "id": "repository" 49 | }, 50 | "Features": { 51 | "href": "https://daybrush.com/scenejs/features.html", 52 | "target": "_blank", 53 | "class": "menu-item", 54 | "id": "features" 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "useWorkspaces": true, 4 | "packages": [ 5 | "packages/*" 6 | ], 7 | "version": "independent", 8 | "lernaHelperOptions": { 9 | "deployFileMap": [ 10 | { 11 | "basePath": "doc", 12 | "dists": [ 13 | "demo/release/{{version}}/doc", 14 | "demo/release/latest/doc" 15 | ] 16 | } 17 | ], 18 | "beforeReleaseScripts": [ 19 | "npm run deploy" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[headers]] 2 | for = "/*" 3 | [headers.values] 4 | Cross-Origin-Embedder-Policy = "require-corp" 5 | Cross-Origin-Opener-Policy = "same-origin" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scenejs-render-root", 3 | "version": "0.0.0", 4 | "description": "Make a movie of CSS animation through Scene.js", 5 | "sideEffects": false, 6 | "private": true, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/daybrush/scenejs-render.git" 10 | }, 11 | "keywords": [ 12 | "scene", 13 | "scene.js", 14 | "scenejs", 15 | "keyframes", 16 | "timeline", 17 | "animate", 18 | "animation", 19 | "css", 20 | "requestAnimationFrame", 21 | "motion" 22 | ], 23 | "author": "Daybrush", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/daybrush/scenejs-render/issues" 27 | }, 28 | "homepage": "https://daybrush.com/scenejs-render", 29 | "scripts": { 30 | "packages:update": "lerna-helper version", 31 | "packages:build": "lerna run build --ignore recorder-react-demo", 32 | "packages:publish": "lerna-helper publish --commit 'chore: publish packages'", 33 | "demo:build": "npm run build --prefix ./packages/recorder-react-demo", 34 | "changelog": "lerna-helper changelog --type all --base @scenejs/render", 35 | "doc": "rm -rf ./doc && jsdoc -c jsdoc.json", 36 | "deploy": "lerna-helper deploy --base @scenejs/render", 37 | "release": "lerna-helper release --base @scenejs/render" 38 | }, 39 | "devDependencies": { 40 | "@daybrush/jsdoc": "^0.4.4", 41 | "@daybrush/release": "^0.7.1", 42 | "daybrush-jsdoc-template": "^1.8.0", 43 | "lerna": "^4.0.0", 44 | "typescript": "^4.5.0 <4.6.0" 45 | }, 46 | "workspaces": { 47 | "packages": [ 48 | "packages/*" 49 | ], 50 | "nohoist": [ 51 | "**/@ffmpeg/*", 52 | "**/@ffmpeg/*/**" 53 | ] 54 | }, 55 | "resolutions": { 56 | "@daybrush/utils": "^1.10.0", 57 | "typescript": "^4.5.0 <4.6.0", 58 | "scenejs": "^1.9.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recorder-react-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@daybrush/utils": "^1.10.2", 7 | "@scena/react-store": "^0.2.1", 8 | "@scenejs/effects": "^1.0.1", 9 | "@scenejs/recorder": "~0.15.0", 10 | "dom-to-image-more": "^2.13.1", 11 | "html-to-image": "^1.11.2", 12 | "react-scenejs": "^2.0.0-beta.10", 13 | "react-shape-svg": "^0.1.4", 14 | "scenejs": "^1.9.4", 15 | "shape-svg": "^0.3.3" 16 | }, 17 | "scripts": { 18 | "start": "rm -rf ./node_modules/.cache && react-scripts start", 19 | "build": "CI='' react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest" 27 | ] 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | }, 41 | "devDependencies": { 42 | "@ffmpeg/core": "^0.11.0", 43 | "@testing-library/jest-dom": "^5.16.5", 44 | "@testing-library/react": "^13.4.0", 45 | "@testing-library/user-event": "^13.5.0", 46 | "@types/dom-to-image": "^2.6.4", 47 | "@types/jest": "^27.5.2", 48 | "@types/node": "^16.18.9", 49 | "@types/react": "^18.0.26", 50 | "@types/react-dom": "^18.0.9", 51 | "react": "^18.2.0", 52 | "react-dom": "^18.2.0", 53 | "react-scripts": "5.0.1", 54 | "typescript": "^4.9.4", 55 | "web-vitals": "^2.1.4" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/clapperboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/packages/recorder-react-demo/public/clapperboard.png -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/packages/recorder-react-demo/public/favicon.ico -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Scene.js Recorder 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/packages/recorder-react-demo/public/logo192.png -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/packages/recorder-react-demo/public/logo512.png -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/App.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Montserrat:400,700); 2 | 3 | .App { 4 | /* text-align: center; */ 5 | color: #333; 6 | background-color: white; 7 | margin: 0 auto; 8 | padding: 0 20px; 9 | font-family: 'Helvetica Neue', Helvetica, sans-serif; 10 | font-size: 16px; 11 | } 12 | 13 | a { 14 | /* text-decoration: none; */ 15 | color: #333; 16 | } 17 | 18 | .gallery { 19 | max-width: 800px; 20 | margin: auto; 21 | } 22 | 23 | .gallery-item { 24 | position: relative; 25 | } 26 | 27 | .name, 28 | .signature { 29 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 30 | font-size: 18px; 31 | margin: 1em 0 0.2em; 32 | color: #4d4e53; 33 | } 34 | 35 | ul { 36 | padding: 0; 37 | padding-left: 15px; 38 | } 39 | 40 | li { 41 | padding: 1px 0px; 42 | font-size: 15px; 43 | } 44 | 45 | .record-container { 46 | background: rgba(0, 0, 0, 0.2); 47 | position: fixed; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | height: 100%; 52 | z-index: 1; 53 | } 54 | 55 | .progress-container { 56 | position: absolute; 57 | top: 50%; 58 | left: 50%; 59 | transform: translate(-50%, -50%); 60 | max-width: 100%; 61 | max-height: 100%; 62 | width: 600px; 63 | background: #333; 64 | color: #fff; 65 | box-sizing: border-box; 66 | padding: 20px; 67 | overflow: auto; 68 | } 69 | 70 | .progress-container h3 { 71 | padding: 0; 72 | margin: 0; 73 | } 74 | 75 | .progress-container p { 76 | margin: 20px 0px; 77 | } 78 | 79 | .progress-total { 80 | width: 100%; 81 | height: 6px; 82 | border-radius: 3px; 83 | background: #ccc; 84 | } 85 | 86 | .progress-thumb { 87 | border-radius: 3px; 88 | height: 6px; 89 | background: #4af; 90 | } 91 | 92 | .progress-container .cancel { 93 | cursor: pointer; 94 | appearance: none; 95 | -webkit-appearance: none; 96 | padding: 10px 15px; 97 | border: 1px solid #eee; 98 | background: transparent; 99 | color: #fff; 100 | font-weight: bold; 101 | ; 102 | } 103 | 104 | .gallery-item .record, .gallery-item .capture { 105 | position: absolute; 106 | width: 30px; 107 | height: 30px; 108 | border: 5px solid #f55; 109 | border-radius: 50%; 110 | box-sizing: border-box; 111 | cursor: pointer; 112 | right: 20px; 113 | top: 40px; 114 | transition: border-width ease 0.2s; 115 | } 116 | .gallery-item .capture { 117 | border-color: #ee5; 118 | right: 100px; 119 | display: none; 120 | } 121 | 122 | .gallery-item .record:hover, .gallery-item .capture:hover { 123 | border-width: 15px; 124 | } 125 | 126 | 127 | .gallery-item .record::before, .gallery-item .capture::before { 128 | pointer-events: none; 129 | content: "Record"; 130 | position: absolute; 131 | left: 50%; 132 | top: calc(15px + 10px); 133 | transform: translate(-50%); 134 | padding: 1px 2px; 135 | font-weight: bold; 136 | font-size: 14px; 137 | } 138 | .gallery-item .capture::before { 139 | content: "Capture"; 140 | } 141 | 142 | .example { 143 | position: relative; 144 | width: 100%; 145 | height: 400px; 146 | border: 1px solid #ccc; 147 | overflow: hidden; 148 | } 149 | 150 | .example::before { 151 | content: "Animation"; 152 | position: absolute; 153 | left: 10px; 154 | top: 5px; 155 | font-weight: bold; 156 | font-size: 15px; 157 | color: #333; 158 | } 159 | 160 | .result { 161 | position: relative; 162 | width: 100%; 163 | border: 1px solid #ccc; 164 | overflow: hidden; 165 | display: none; 166 | } 167 | 168 | .result::before { 169 | content: "Recorded Video"; 170 | position: absolute; 171 | left: 10px; 172 | top: 5px; 173 | font-weight: bold; 174 | font-size: 15px; 175 | color: #333; 176 | z-index: 10; 177 | transform: translateZ(1px); 178 | } 179 | 180 | .result video { 181 | position: absolute; 182 | left: 50%; 183 | transform: translate(-50%); 184 | display: block; 185 | margin: 0px auto; 186 | max-height: 100%; 187 | max-width: 100%; 188 | } 189 | 190 | .example-viewer { 191 | position: absolute; 192 | margin-left: 50%; 193 | transform: translate(-50%); 194 | height: 100%; 195 | } 196 | 197 | .example-container { 198 | position: relative; 199 | height: 100%; 200 | } 201 | 202 | .example-hover { 203 | position: absolute; 204 | width: 100%; 205 | height: 100%; 206 | background: rgba(0, 0, 0, 0.1); 207 | display: block; 208 | opacity: 0; 209 | pointer-events: none; 210 | transition: opacity ease 0.2s; 211 | } 212 | 213 | .example:hover .example-hover { 214 | pointer-events: auto; 215 | opacity: 1; 216 | } 217 | 218 | .example-hover .button { 219 | position: absolute; 220 | top: 50%; 221 | left: 50%; 222 | transform: translate(-50%, -50%); 223 | display: none; 224 | } 225 | 226 | .example-hover .button.display { 227 | display: block; 228 | cursor: pointer; 229 | } 230 | 231 | .example-hover .button.display svg { 232 | width: 50px; 233 | } 234 | 235 | .example-hover .paused { 236 | width: 40px; 237 | height: 50px; 238 | } 239 | 240 | .example-hover .paused:before, 241 | .example-hover .paused:after { 242 | position: absolute; 243 | content: ""; 244 | width: 10px; 245 | height: 100%; 246 | background: #333; 247 | border-radius: 5px; 248 | } 249 | 250 | .example-hover .paused:after { 251 | right: 0; 252 | } 253 | 254 | /* progress */ 255 | 256 | .player { 257 | position: relative; 258 | text-align: center; 259 | display: flex; 260 | align-items: center; 261 | padding: 10px 0px; 262 | } 263 | 264 | .player .play, 265 | .player .pause, 266 | .player .progress { 267 | display: inline-block; 268 | vertical-align: middle; 269 | } 270 | 271 | .player .play { 272 | cursor: pointer; 273 | border-left: 14px solid #333; 274 | border-top: 8px solid transparent; 275 | border-bottom: 8px solid transparent; 276 | margin-right: 15px; 277 | } 278 | 279 | .player .pause { 280 | cursor: pointer; 281 | border-left: 4px solid #333; 282 | border-right: 4px solid #333; 283 | width: 14px; 284 | height: 16px; 285 | box-sizing: border-box; 286 | margin-right: 15px; 287 | } 288 | 289 | .player input[type=range] { 290 | width: 100%; 291 | } 292 | 293 | .player input[type=range] { 294 | -webkit-appearance: none; 295 | background: rgba(0, 0, 0, 0.2); 296 | border-radius: 2.5px; 297 | height: 5px; 298 | } 299 | 300 | .player input[type=range]::-webkit-slider-thumb { 301 | -webkit-appearance: none; 302 | width: 14px; 303 | height: 14px; 304 | border-radius: 7px; 305 | background: #333; 306 | cursor: move; 307 | } 308 | 309 | .player input[type=range]:focus { 310 | outline: none; 311 | } 312 | 313 | 314 | 315 | /* Motion Effect */ 316 | .motion { 317 | position: absolute; 318 | left: 0; 319 | right: 0; 320 | top: 0; 321 | bottom: 0; 322 | width: 200px; 323 | height: 200px; 324 | margin: auto; 325 | overflow: hidden; 326 | } 327 | 328 | .motion .star { 329 | position: absolute; 330 | left: 0; 331 | right: 0; 332 | top: 0; 333 | width: 20px; 334 | height: 20px; 335 | margin: auto; 336 | } 337 | 338 | .motion .star::before { 339 | content: ""; 340 | position: absolute; 341 | width: 100%; 342 | height: 100%; 343 | bottom: 50%; 344 | right: 50%; 345 | border: 8px solid #5a5; 346 | border-bottom: 0; 347 | border-right: 0; 348 | transform: skew(9deg, 9deg); 349 | transform-origin: 100% 100%; 350 | } 351 | 352 | .motion .star>.star { 353 | transform: rotate(72deg); 354 | } 355 | 356 | .motion .triangle { 357 | position: absolute; 358 | left: 0; 359 | right: 0; 360 | top: 0px; 361 | bottom: 20px; 362 | margin: auto; 363 | width: 100px; 364 | height: 10px; 365 | border-radius: 5px; 366 | background: #f5f; 367 | } 368 | 369 | .motion .triangle:before, 370 | .motion .triangle:after { 371 | content: ""; 372 | position: absolute; 373 | width: 100%; 374 | height: 100%; 375 | border-radius: inherit; 376 | background: inherit; 377 | } 378 | 379 | .motion .triangle:before { 380 | transform-origin: 5px 50%; 381 | transform: rotate(60deg); 382 | } 383 | 384 | .motion .triangle:after { 385 | transform-origin: calc(100% - 5px) 50%; 386 | transform: rotate(-60deg); 387 | } 388 | 389 | .motion .rectangle { 390 | position: absolute; 391 | left: 0; 392 | right: 0; 393 | top: 0; 394 | bottom: 0; 395 | margin: auto; 396 | width: 80px; 397 | height: 80px; 398 | border-radius: 5px; 399 | border: 10px solid #f55; 400 | } 401 | 402 | .motion .circle { 403 | position: absolute; 404 | width: 140px; 405 | height: 140px; 406 | border-radius: 50%; 407 | box-sizing: border-box; 408 | border: 70px solid #5ff; 409 | left: 0; 410 | right: 0; 411 | top: -20px; 412 | bottom: -20px; 413 | margin: auto; 414 | } 415 | 416 | .motion .rectangle2 { 417 | border-color: #55f; 418 | } 419 | 420 | .motion .circle2 { 421 | width: 40px; 422 | height: 40px; 423 | bottom: 20px; 424 | border: 8px solid #EED414; 425 | } 426 | 427 | .motion .star1 { 428 | top: 0; 429 | bottom: 20px; 430 | } 431 | 432 | 433 | 434 | 435 | /* Page 1 */ 436 | 437 | 438 | .page1 { 439 | position: relative; 440 | height: 100%; 441 | width: 400px; 442 | margin: auto; 443 | } 444 | 445 | .page1 .startAnimation { 446 | will-change: transform; 447 | -webkit-backface-visibility: hidden; 448 | -webkit-perspective: 1000; 449 | } 450 | 451 | .page1 .logo { 452 | position: absolute; 453 | top: 0px; 454 | left: 0px; 455 | right: 0px; 456 | bottom: 0px; 457 | height: 250px; 458 | margin: auto; 459 | } 460 | 461 | .page1 .logo1 .clapper { 462 | position: absolute; 463 | width: 200px; 464 | height: 110px; 465 | top: 50%; 466 | left: 50%; 467 | margin-left: -100px; 468 | margin-top: -55px; 469 | } 470 | 471 | .page1 .logo1 .svg_circle { 472 | position: absolute; 473 | } 474 | 475 | .page1 .logo1 svg circle { 476 | stroke-dasharray: 0 1000; 477 | stroke-dashoffset: 0; 478 | } 479 | 480 | .page1 .logo1 .play-btn { 481 | width: 40px; 482 | position: absolute; 483 | top: 50%; 484 | left: 50%; 485 | transform: translate(-50%, -50%) translate(5px); 486 | transition: width ease 0.2s; 487 | } 488 | 489 | .page1 .logo1 .play-btn:hover { 490 | width: 45px; 491 | } 492 | 493 | .page1 .logo1 .circle { 494 | position: absolute; 495 | left: 0; 496 | right: 0; 497 | top: 0; 498 | bottom: 0; 499 | margin: auto; 500 | z-index: -1; 501 | } 502 | .page1 .logo1 .center { 503 | position: absolute; 504 | z-index: -1; 505 | top: 50%; 506 | left: 50%; 507 | transform: translate(-50%, -50%); 508 | } 509 | 510 | .page1 .logo1 .circle { 511 | transform: scale(0); 512 | } 513 | 514 | .page1 .logo1 .circle2 { 515 | bottom: 100px; 516 | left: 50px; 517 | } 518 | 519 | .page1 .logo1 .circle3 { 520 | top: 50px; 521 | right: 100px; 522 | } 523 | 524 | .page1 .logo1 .circle4 { 525 | left: 100px; 526 | top: 50px; 527 | } 528 | 529 | .page1 .logo1 .circle5 { 530 | right: 100px; 531 | bottom: 50px; 532 | } 533 | 534 | .page1 .logo1 .circle { 535 | position: absolute; 536 | box-sizing: border-box; 537 | border-style: solid; 538 | border-radius: 50%; 539 | width: 100px; 540 | height: 100px; 541 | border-width: 50px; 542 | border-color: transparent; 543 | } 544 | 545 | .page1 .logo1 .circle:before, 546 | .page1 .logo1 .circle:after { 547 | content: ""; 548 | position: absolute; 549 | top: 50%; 550 | left: 50%; 551 | border-style: solid; 552 | width: inherit; 553 | height: inherit; 554 | border-width: inherit; 555 | box-sizing: border-box; 556 | border-radius: 50%; 557 | } 558 | 559 | .page1 .logo1 .circle:before { 560 | transform: translate(-50%, -50%); 561 | margin-top: 15%; 562 | margin-left: 15%; 563 | border-color: #D6D6D6; 564 | } 565 | 566 | .page1 .logo1 .circle:after { 567 | transform: translate(-50%, -50%); 568 | border-color: #333; 569 | } 570 | 571 | .page1 .logo1 ellipse { 572 | opacity: 0; 573 | } 574 | 575 | .page1 .logo1 .background { 576 | position: absolute; 577 | top: 50%; 578 | left: 50%; 579 | width: 200px; 580 | height: 110px; 581 | border-radius: 5px; 582 | transform: translate(-50%, -50%); 583 | } 584 | 585 | .page1 .play-circle { 586 | position: absolute; 587 | top: 50%; 588 | left: 50%; 589 | width: 70px; 590 | height: 70px; 591 | background: #fff; 592 | border-radius: 50%; 593 | transform: translate(-50%, -50%); 594 | z-index: 1; 595 | } 596 | 597 | .page1 .play-btn.front { 598 | z-index: 2; 599 | } 600 | 601 | .page1 .clapper .stick { 602 | position: absolute; 603 | box-sizing: border-box; 604 | width: 200px; 605 | height: 32px; 606 | font-size: 0; 607 | overflow: hidden; 608 | white-space: nowrap; 609 | padding: 5px 8px; 610 | text-align: center; 611 | background: #333; 612 | } 613 | 614 | .page1 .clapper .stick1 { 615 | transform-origin: 0% 100%; 616 | border-radius: 5px 5px 0px 0px; 617 | top: -60px; 618 | } 619 | 620 | .page1 .clapper .stick2 { 621 | top: -30px; 622 | } 623 | 624 | .page1 .clapper .rect { 625 | position: relative; 626 | display: inline-block; 627 | height: 100%; 628 | width: 20px; 629 | background: white; 630 | vertical-align: top; 631 | margin: 0px 5px 0px; 632 | border-radius: 5px; 633 | } 634 | 635 | .page1 .clapper .stick1 .rect { 636 | transform: skew(15deg); 637 | } 638 | 639 | .page1 .clapper .stick2 .rect { 640 | transform: skew(-15deg); 641 | } 642 | 643 | .page1 .logo1 .bottom { 644 | position: absolute; 645 | top: 0; 646 | left: 0; 647 | width: 100%; 648 | height: 100%; 649 | background: #333; 650 | box-sizing: border-box; 651 | border-radius: 0px 0px 5px 5px; 652 | } 653 | 654 | .page1 .logo1 .shadow { 655 | position: absolute; 656 | box-sizing: border-box; 657 | background: #D6D6D6; 658 | margin-top: 10px; 659 | margin-left: 10px; 660 | z-index: -1; 661 | } 662 | 663 | /* Clapper 1 */ 664 | 665 | .clapper1 { 666 | position: absolute; 667 | top: 50%; 668 | left: 50%; 669 | transform: translate(-50%, -50%); 670 | width: 200px; 671 | height: 200px; 672 | z-index: 2; 673 | transform-origin: 20% 60%; 674 | will-change: transform; 675 | -webkit-backface-visibility: hidden; 676 | -webkit-perspective: 1000; 677 | } 678 | 679 | .clapper1 .clapper-container { 680 | position: absolute; 681 | margin: auto; 682 | width: 200px; 683 | height: 170px; 684 | left: -200px; 685 | right: -200px; 686 | top: -200px; 687 | bottom: -200px; 688 | } 689 | 690 | .clapper1 .clapper-container .clapper-body { 691 | position: relative; 692 | width: 100%; 693 | height: 100%; 694 | } 695 | 696 | .clapper1 .stick { 697 | position: absolute; 698 | box-sizing: border-box; 699 | width: 200px; 700 | height: 32px; 701 | font-size: 0; 702 | overflow: hidden; 703 | white-space: nowrap; 704 | padding: 5px 8px; 705 | text-align: center; 706 | background: #333; 707 | } 708 | 709 | .clapper1 .stick1 { 710 | transform-origin: 0% 100%; 711 | border-radius: 5px 5px 0px 0px; 712 | } 713 | 714 | .clapper1 .stick2 { 715 | top: 30px; 716 | } 717 | 718 | .clapper1 .rect { 719 | position: relative; 720 | display: inline-block; 721 | height: 100%; 722 | width: 20px; 723 | background: white; 724 | vertical-align: top; 725 | margin: 0px 5px 0px; 726 | border-radius: 5px; 727 | } 728 | 729 | .clapper1 .stick1 .rect { 730 | transform: skew(15deg); 731 | } 732 | 733 | .clapper1 .stick2 .rect { 734 | transform: skew(-15deg); 735 | } 736 | 737 | .clapper1 .top { 738 | position: absolute; 739 | top: 0; 740 | border-radius: 5px 5px 0px 0px; 741 | width: 100%; 742 | height: 37%; 743 | } 744 | 745 | .clapper1 .bottom { 746 | position: absolute; 747 | bottom: 0; 748 | width: 100%; 749 | height: 64%; 750 | background: #333; 751 | border-bottom-left-radius: 5px; 752 | border-bottom-right-radius: 5px; 753 | } 754 | 755 | .clapper1 .circle { 756 | position: absolute; 757 | left: 0; 758 | right: 0; 759 | bottom: 10%; 760 | margin: auto; 761 | width: 70px; 762 | height: 70px; 763 | background: white; 764 | border-radius: 50%; 765 | } 766 | 767 | .clapper1 .play { 768 | position: absolute; 769 | left: 50%; 770 | margin-left: 3px; 771 | bottom: 7%; 772 | transform: translate(-50%, -50%); 773 | width: 32px; 774 | /* overflow: hidden; */ 775 | } 776 | 777 | .clapper1 .play svg { 778 | -webkit-backface-visibility: hidden; 779 | outline: 1px solid transparent; 780 | } 781 | 782 | 783 | /* Card */ 784 | 785 | .card-rotor { 786 | position: relative; 787 | width: 330px; 788 | height: 350px; 789 | margin: 0px auto; 790 | } 791 | 792 | .card-wrapper { 793 | position: absolute; 794 | width: 100%; 795 | height: 100%; 796 | backface-visibility: hidden; 797 | -webkit-backface-visibility: hidden; 798 | -moz-backface-visibility: hidden; 799 | -ms-backface-visibility: hidden; 800 | z-index: 1; 801 | } 802 | 803 | .card { 804 | width: 192px; 805 | height: 267px; 806 | border-radius: 10px; 807 | background: #f1f1f1; 808 | box-sizing: border-box; 809 | position: absolute; 810 | bottom: 10px; 811 | } 812 | 813 | .forward .card { 814 | right: 50%; 815 | transform: rotate(35deg); 816 | transform-origin: 100% 100%; 817 | } 818 | 819 | .backward .card { 820 | left: 50%; 821 | transform: rotate(-35deg); 822 | transform-origin: 0% 100%; 823 | } 824 | 825 | .card .mark { 826 | position: absolute; 827 | width: 60%; 828 | left: 50%; 829 | top: 50%; 830 | transform: translate(-50%, -50%) rotate(0deg); 831 | text-align: center; 832 | font-size: 100px; 833 | color: #dd0; 834 | } 835 | 836 | .card .shadow { 837 | position: absolute; 838 | bottom: 0; 839 | left: 0; 840 | right: 0; 841 | margin: auto; 842 | width: 60px; 843 | height: 10px; 844 | border-radius: 50%; 845 | background: #ccc; 846 | } 847 | 848 | .card .crown { 849 | width: 70px; 850 | height: 80px; 851 | margin: 0px auto; 852 | } 853 | 854 | .card .crown:after { 855 | content: ""; 856 | position: absolute; 857 | height: 10px; 858 | width: 100%; 859 | background: #dd0; 860 | left: 0; 861 | bottom: 0; 862 | } 863 | 864 | .crown div { 865 | position: absolute; 866 | border-bottom: 80px solid #dd0; 867 | } 868 | 869 | .crown div:after { 870 | position: absolute; 871 | content: ""; 872 | width: 12px; 873 | height: 12px; 874 | border-radius: 50%; 875 | bottom: 0; 876 | transform: translate(-50%, -5px); 877 | background: #dd0; 878 | } 879 | 880 | .crown .center { 881 | border-left: 35px solid transparent; 882 | border-right: 35px solid transparent; 883 | } 884 | 885 | .crown .left { 886 | transform-origin: 0% 100%; 887 | transform: rotate(-10deg); 888 | border-right: 80px solid transparent; 889 | left: 0; 890 | } 891 | 892 | .crown .right { 893 | transform-origin: 100% 100%; 894 | transform: rotate(10deg); 895 | border-left: 80px solid transparent; 896 | right: 0; 897 | } 898 | 899 | 900 | /* Square Transition*/ 901 | .square-transition-container { 902 | position: relative; 903 | width: 100%; 904 | height: 100%; 905 | font-size: 0; 906 | } 907 | .square-transition { 908 | position: absolute; 909 | z-index: 10; 910 | width: 100%; 911 | height: 100%; 912 | } 913 | .square-transition-container .scene, .smoke-transition-container .scene { 914 | position: absolute; 915 | width: 100%; 916 | height: 100%; 917 | } 918 | .square-transition-container .scene span, .smoke-transition-container .scene span { 919 | position: absolute; 920 | top: 50%; 921 | left: 50%; 922 | transform: translate(-50%, -50%); 923 | font-size: 40px; 924 | font-weight: bold; 925 | } 926 | 927 | .square-transition .squares { 928 | position: relative; 929 | white-space: nowrap; 930 | } 931 | .square-transition .square { 932 | display: inline-block; 933 | width: 120px; 934 | height: 120px; 935 | background: #333; 936 | } 937 | 938 | 939 | /* Smoke Transition Circle */ 940 | .smoke-transition-container { 941 | position: relative; 942 | width: 100%; 943 | height: 100%; 944 | font-size: 0; 945 | } 946 | .smoke-transition { 947 | position: relative; 948 | width: 100%; 949 | height: 100%; 950 | } 951 | .smoke-transition .circle { 952 | position: absolute; 953 | right: 0; 954 | bottom: 0; 955 | width: 120px; 956 | height: 120px; 957 | border-radius: 50%; 958 | } 959 | .smoke-transition .circles { 960 | position: absolute; 961 | width: 100%; 962 | height: 100%; 963 | } 964 | .smoke-transition .circles1 .circle { 965 | background-color: #920733; 966 | } 967 | .smoke-transition .circles2 .circle { 968 | background-color: #BDD4CC; 969 | } 970 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import './App.css'; 3 | import { StoreRoot } from "@scena/react-store"; 4 | import { Gallery } from './Gallery'; 5 | 6 | 7 | function App() { 8 | return ( 9 |
10 |

11 | Clapperboard 14 |

15 |

Scene.js Recorder

16 | 17 | 18 |

🎬 Make a movie of CSS animation through Scene.js

19 |

20 | Github 21 |  /  22 | API 23 |  /  24 | Scene.js 25 |  /  26 | Main Project 27 |

28 | 29 |
30 | 31 | 32 | 33 | 34 |
35 | ); 36 | } 37 | 38 | export default App; 39 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { ReactSceneResult, useScene } from "react-scenejs"; 4 | 5 | export const Card = React.forwardRef((_, ref) => { 6 | const scene = useScene({ 7 | ".card-wrapper.forward": { 8 | 0: { 9 | transform: "rotateY(0deg)", 10 | }, 11 | 0.5: "opacity: 1", 12 | "0.5>": "opacity: 0", 13 | 1: { 14 | transform: "rotateY(180deg)", 15 | }, 16 | 1.5: "opacity: 0", 17 | "1.5>": "opacity: 1", 18 | 2: { 19 | transform: "rotateY(360deg)" 20 | }, 21 | }, 22 | ".card-wrapper.backward": { 23 | 0: { 24 | transform: "rotateY(180deg)", 25 | }, 26 | 0.5: "opacity: 0", 27 | "0.5>": "opacity: 1", 28 | 1: { 29 | transform: "rotateY(360deg)", 30 | }, 31 | 1.5: "opacity: 1", 32 | "1.5>": "opacity: 0", 33 | 2: { 34 | transform: "rotateY(540deg)" 35 | }, 36 | }, 37 | ".shadow": { 38 | 0: { 39 | transform: "scaleX(1)", 40 | easing: "ease-in", 41 | }, 42 | 0.5: { 43 | transform: "scaleX(0.16)", 44 | easing: "ease-out", 45 | }, 46 | 1: { 47 | transform: "scaleX(1)", 48 | }, 49 | options: { 50 | iterationCount: 2, 51 | } 52 | } 53 | }, { 54 | selector: true, 55 | iterationCount: 2, 56 | }); 57 | 58 | React.useImperativeHandle(ref, () => scene, []); 59 | return
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | K 75 |
76 |
77 |
78 |
79 |
80 | }); 81 | 82 | Card.displayName = "Card"; 83 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/Clapper1/Clapper1.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { EASE_IN_OUT, selectorAll } from "scenejs"; 4 | import { ReactSceneResult, useScene } from "react-scenejs"; 5 | import { zoomIn } from "@scenejs/effects"; 6 | import { Poly } from "react-shape-svg"; 7 | 8 | export const Clapper1 = React.forwardRef((_, ref) => { 9 | const scene = useScene(() => { 10 | return { 11 | ".clapper1": { 12 | 2: "transform: translate(-50%, -50%) rotate(0deg)", 13 | 2.5: { 14 | transform: "rotate(-15deg)", 15 | }, 16 | 3: { 17 | transform: "rotate(0deg)", 18 | }, 19 | 3.5: { 20 | transform: "rotate(-10deg)", 21 | }, 22 | }, 23 | ".clapper1 .clapper-container": { 24 | 0: zoomIn({ duration: 1 }), 25 | }, 26 | ".clapper1 .circle": { 27 | 0.3: zoomIn({ duration: 1 }), 28 | }, 29 | ".clapper1 .play": { 30 | 0: { 31 | transform: "translate(-50%, -50%)", 32 | }, 33 | 0.6: zoomIn({ duration: 1 }), 34 | }, 35 | ".clapper1 .top .stick1": { 36 | 2: { 37 | transform: { 38 | rotate: "0deg", 39 | }, 40 | }, 41 | 2.5: { 42 | transform: { 43 | rotate: "-20deg", 44 | } 45 | }, 46 | 3: { 47 | transform: { 48 | rotate: "0deg", 49 | } 50 | }, 51 | 3.5: { 52 | transform: { 53 | rotate: "-10deg", 54 | } 55 | }, 56 | }, 57 | ".clapper1 .stick1 .rect": selectorAll(i => ({ 58 | 0: { 59 | transform: { 60 | scale: 0, 61 | skew: "15deg", 62 | } 63 | }, 64 | 0.7: { 65 | transform: { 66 | scale: 1, 67 | } 68 | }, 69 | options: { 70 | delay: 0.6 + i * 0.1, 71 | }, 72 | }), 6), 73 | ".clapper1 .stick2 .rect": selectorAll(i => ({ 74 | 0: { 75 | transform: { 76 | scale: 0, 77 | skew: "-15deg", 78 | } 79 | }, 80 | 0.7: { 81 | transform: { 82 | scale: 1, 83 | } 84 | }, 85 | options: { 86 | delay: 0.8 + i * 0.1, 87 | }, 88 | }), 6), 89 | }; 90 | }, { 91 | easing: EASE_IN_OUT, 92 | selector: true, 93 | }); 94 | 95 | 96 | React.useImperativeHandle(ref, () => scene, []); 97 | 98 | return
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | 134 |
135 |
136 |
; 137 | }); 138 | 139 | Clapper1.displayName = "Clapper1"; -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/Clapper2/Clapper2.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import Scene, { EASE_IN_OUT, selectorAll } from "scenejs"; 4 | import { ReactSceneResult, useScene } from "react-scenejs"; 5 | import { PolyShape } from "shape-svg"; 6 | import { Oval, Poly } from "react-shape-svg"; 7 | 8 | 9 | 10 | function makeShadow(options: PolyShape, left = 10, top = 15) { 11 | return { 12 | target: { 13 | left, 14 | top, 15 | right: left, 16 | bottom: top, 17 | opacity: 1, 18 | ...options, 19 | }, 20 | shadow: { 21 | left: left * 2, 22 | top: top * 2, 23 | opacity: 0.2, 24 | ...options, 25 | }, 26 | }; 27 | } 28 | 29 | interface TargetShadowProps { 30 | component: typeof Poly | typeof Oval; 31 | options: PolyShape; 32 | left?: number; 33 | top?: number; 34 | } 35 | 36 | function TargetShadow(props: TargetShadowProps) { 37 | const targetInfo = makeShadow(props.options, props.left, props.top); 38 | const Component = props.component; 39 | 40 | return <> 41 | 42 | 43 | ; 44 | } 45 | 46 | const r = 50; 47 | 48 | export const Clapper2 = React.forwardRef((_, ref) => { 49 | const elements = React.useMemo(() => { 50 | const elements: React.ReactElement[] = []; 51 | 52 | for (let i = 1; i <= 6; ++i) { 53 | const size = (170 - (i - 1) * 20); 54 | const stroke = r * 12 / size; 55 | const ir = r - stroke; 56 | 57 | const info = makeShadow({ 58 | "className": `svg_circle svg_circle${i} center`, 59 | "r": ir, 60 | "strokeWidth": stroke, 61 | "strokeLinejoin": "round", 62 | "stroke-linecap": "round", 63 | "stroke": "#333", 64 | "rotate": -360, 65 | "origin": "50% 50%", 66 | }, 5, 5); 67 | 68 | 69 | elements.push( 70 | , 74 | , 78 | ); 79 | } 80 | return elements; 81 | }, []); 82 | const scene = useScene(() => { 83 | const nextStep = 2.6; 84 | const nextStep2 = nextStep + 4; 85 | 86 | return new Scene({ 87 | ".page1 .logo1 .scene1.circle": selectorAll(i => ({ 88 | 0: { 89 | transform: "scale(0)", 90 | }, 91 | 0.2: { 92 | "border-width": "50px", 93 | }, 94 | 0.5: { 95 | opacity: 1, 96 | }, 97 | 1: { 98 | "transform": "scale(1)", 99 | "border-width": "0px", 100 | "opacity": 0, 101 | }, 102 | options: { 103 | delay: i * 0.4, 104 | }, 105 | }), 6), 106 | ".page1 .logo1 ellipse": selectorAll(i => { 107 | const index = Math.floor(i / 2); 108 | 109 | return { 110 | 0: { 111 | "opacity": 0, 112 | "stroke-dasharray": "0 1000", 113 | "transform": `scaleX(${index % 2 ? -1 : 1}) rotate(${-90 + index * 180}deg)` 114 | }, 115 | 0.1: { 116 | opacity: i % 2 ? 0.2 : 1, 117 | }, 118 | 0.6: { 119 | "stroke-dasharray": `${70} 1000`, 120 | "stroke-dashoffset": 0, 121 | }, 122 | [1.1 - index * 0.06]: { 123 | opacity: i % 2 ? 0.2 : 1, 124 | }, 125 | [1.2 - index * 0.06]: { 126 | "stroke-dashoffset": -76, 127 | "stroke-dasharray": "0 1000", 128 | "transform": `rotate(${180 + index * 180}deg)`, 129 | "opacity": 0 130 | }, 131 | options: { 132 | delay: nextStep + index * 0.16, 133 | } 134 | }; 135 | }, 12), 136 | ".page1 .play-btn.back": { 137 | 0: { 138 | transform: 'translate(-50%, -50%) scale(1)', 139 | }, 140 | 1: { 141 | transform: 'scale(0.5)', 142 | }, 143 | 2: { 144 | transform: 'scale(1)', 145 | }, 146 | options: { 147 | delay: nextStep + 1, 148 | } 149 | }, 150 | ".page1 .play-btn.front": { 151 | 0: { 152 | transform: 'translate(-50%, -50%) scale(0)', 153 | }, 154 | 1: { 155 | transform: 'scale(1)', 156 | }, 157 | options: { 158 | delay: nextStep + 2.4, 159 | } 160 | }, 161 | ".page1 .play-circle": { 162 | 0: { 163 | transform: 'translate(-50%, -50%) scale(0)', 164 | }, 165 | 1: { 166 | transform: 'scale(1)', 167 | }, 168 | options: { 169 | delay: nextStep + 2.1, 170 | } 171 | }, 172 | ".page1 .background": { 173 | 0: { 174 | transform: 'translate(-50%, -50%) scale(0)', 175 | }, 176 | 1: { 177 | transform: 'scale(1)', 178 | }, 179 | options: { 180 | delay: nextStep + 1.8, 181 | } 182 | }, 183 | ".page1 .stick1 .rect": selectorAll(i => ({ 184 | 0: { 185 | transform: { 186 | scale: 0, 187 | skew: "15deg", 188 | } 189 | }, 190 | 0.7: { 191 | transform: { 192 | scale: 1, 193 | } 194 | }, 195 | options: { 196 | delay: nextStep + 2.8 + i * 0.1, 197 | }, 198 | }), 6), 199 | ".page1 .stick2 .rect": selectorAll(i => ({ 200 | 0: { 201 | transform: { 202 | scale: 0, 203 | skew: "-15deg", 204 | } 205 | }, 206 | 0.7: { 207 | transform: { 208 | scale: 1, 209 | } 210 | }, 211 | options: { 212 | delay: nextStep + 3 + i * 0.1, 213 | }, 214 | }), 6), 215 | ".page1 .stick1": { 216 | 0: { 217 | transform: { 218 | rotate: "0deg", 219 | }, 220 | }, 221 | 0.5: { 222 | transform: { 223 | rotate: "-20deg", 224 | } 225 | }, 226 | 1: { 227 | transform: { 228 | rotate: "0deg", 229 | } 230 | }, 231 | 1.5: { 232 | transform: { 233 | rotate: "-10deg", 234 | } 235 | }, 236 | options: { 237 | delay: nextStep2, 238 | easing: EASE_IN_OUT, 239 | } 240 | }, 241 | ".page1 .clapper": { 242 | 0: { 243 | transform: "rotate(0deg)", 244 | }, 245 | 0.5: { 246 | transform: "rotate(-15deg)", 247 | }, 248 | 1: { 249 | transform: "rotate(0deg)", 250 | }, 251 | 1.5: { 252 | transform: "rotate(-10deg)", 253 | }, 254 | options: { 255 | delay: nextStep2, 256 | easing: EASE_IN_OUT, 257 | }, 258 | }, 259 | }, { 260 | easing: EASE_IN_OUT, 261 | selector: true, 262 | iterationCount: 1, 263 | }); 264 | }); 265 | 266 | React.useImperativeHandle(ref, () => scene, []); 267 | 268 | return
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 | {elements} 300 | 313 | 326 |
327 |
328 |
; 329 | }); 330 | 331 | Clapper2.displayName = "Clapper2"; -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/Gallery.tsx: -------------------------------------------------------------------------------- 1 | import { RenderModal } from "./RenderModal"; 2 | import { SquareTransition } from "./SquareTransition/SquareTransition"; 3 | import { Card } from "./Card/Card"; 4 | import { Clapper1 } from "./Clapper1/Clapper1"; 5 | import { Clapper2 } from "./Clapper2/Clapper2"; 6 | import { GalleryItem } from "./GalleryItem/GalleryItem"; 7 | import { MotionEffect } from "./MotionEffect/MotionEffect"; 8 | import { SmokeTransition } from "./SmokeTransition/SmokeTransition"; 9 | 10 | export function Gallery() { 11 | return
12 |

16 | * I recommend checking out the Scene.js Recorder demo in Chrome.
17 | * In Safari, the `html-to-image` module may not render some parts.
18 |   Use `@scenejs/render` in Node env.
19 | * On mobile, memory issues may occur. 20 |

21 | 22 | 28 | 33 | 38 | 43 | 48 | {/* */} 53 |
; 54 | } -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/GalleryItem/GalleryItem.tsx: -------------------------------------------------------------------------------- 1 | import { useStoreStateSetValue } from "@scena/react-store"; 2 | import { toSvg } from "html-to-image"; 3 | import * as React from "react"; 4 | import { ReactSceneResult } from "react-scenejs"; 5 | import { Poly } from "react-shape-svg"; 6 | import { $container, $scene } from "../RenderModal"; 7 | import { Progress } from "./Progress"; 8 | 9 | export interface GalleryItemProps { 10 | sceneComponent: React.ForwardRefExoticComponent>; 11 | title: string; 12 | width: number; 13 | height: number; 14 | } 15 | export function GalleryItem(props: GalleryItemProps) { 16 | const SceneComponent = props.sceneComponent; 17 | const galleryElementRef = React.useRef(null); 18 | const sceneRef = React.useRef(null); 19 | 20 | const setScene = useStoreStateSetValue($scene); 21 | const setContainer = useStoreStateSetValue($container); 22 | const [duration, setDuration] = React.useState("0s"); 23 | 24 | React.useEffect(() => { 25 | sceneRef.current!.setTime(0); 26 | setDuration(`${sceneRef.current!.getTotalDuration()}s`); 27 | }, []); 28 | 29 | React.useEffect(() => { 30 | const galleryElement = galleryElementRef.current!; 31 | const playButton = galleryElement.querySelector(".example-hover .play")!; 32 | const pausedButton = galleryElement.querySelector(".example-hover .paused")!; 33 | const scene = sceneRef.current!; 34 | 35 | function onPlay() { 36 | playButton.classList.remove("display"); 37 | pausedButton.classList.add("display"); 38 | } 39 | function onPaused() { 40 | playButton.classList.add("display"); 41 | pausedButton.classList.remove("display"); 42 | } 43 | 44 | scene.on("play", onPlay); 45 | scene.on("paused", onPaused); 46 | 47 | return () => { 48 | scene.off("play", onPlay); 49 | scene.off("paused", onPaused); 50 | }; 51 | }, [sceneRef]); 52 | 53 | return (
54 |

{props.title}

55 |
    56 |
  • Size: {props.width} x {props.height}
  • 57 |
  • Duration: {duration}
  • 58 |
  • Code: JS / React
  • 59 |
60 |
{ 61 | toSvg(galleryElementRef.current!.querySelector(".example-container")!, { 62 | pixelRatio: 2, 63 | // bgcolor: "#fff", 64 | backgroundColor: "#fff", 65 | skipFonts: true, 66 | style: { 67 | position: "relative", 68 | left: "0px", 69 | top: "0px", 70 | right: "auto", 71 | bottom: "auto", 72 | margin: "0", 73 | }, 74 | filter(node: HTMLElement) { 75 | // console.log(node); 76 | return true; 77 | } 78 | }).then(url => { 79 | const result = galleryElementRef.current!.querySelector(".result")!; 80 | 81 | result.style.display = "block"; 82 | result.innerHTML = ``; 83 | // result.innerHTML = `${decodeURIComponent(url.replace("data:image/svg+xml;charset=utf-8,", ""))}`; 84 | }); 85 | }}>
86 |
{ 87 | setContainer(galleryElementRef.current); 88 | setScene(sceneRef.current!); 89 | }}>
90 |
93 |
94 |
97 | 98 |
99 |
100 |
{ 101 | const scene = sceneRef.current!; 102 | 103 | if (scene.isPaused()) { 104 | scene.play(); 105 | } else { 106 | scene.pause(); 107 | } 108 | }}> 109 |
110 | 121 |
122 |
123 |
124 |
125 | 126 |
129 |
131 |
); 132 | } -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/GalleryItem/Progress.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from "react"; 2 | import { ReactSceneResult } from "react-scenejs"; 3 | import { OnEvent } from "@scena/event-emitter"; 4 | import Scene, { OnSceneAnimate } from "scenejs"; 5 | 6 | 7 | 8 | export interface ProgressProps { 9 | sceneRef: RefObject; 10 | enabled?: boolean; 11 | } 12 | export function Progress(props: ProgressProps) { 13 | const playerRef = useRef(null); 14 | const sceneRef = props.sceneRef; 15 | 16 | useEffect(() => { 17 | const playButton = playerRef.current!.querySelector(".play")!; 18 | const progressInput = playerRef.current!.querySelector(".progress")!; 19 | const scene = sceneRef.current!; 20 | 21 | function onPlay() { 22 | playButton.className = "pause"; 23 | } 24 | function onPaused() { 25 | playButton.className = "play"; 26 | } 27 | function onAnimate(e: OnEvent) { 28 | progressInput.value = `${100 * e.time / e.currentTarget.getDuration()}`; 29 | } 30 | function onClick() { 31 | scene.isPaused() ? scene.play() : scene.pause(); 32 | } 33 | function onInput() { 34 | scene.pause(); 35 | scene.setTime(`${progressInput.value}%`); 36 | } 37 | 38 | scene.on("play", onPlay); 39 | scene.on("paused", onPaused); 40 | scene.on("animate", onAnimate); 41 | 42 | playButton.addEventListener("click", onClick); 43 | progressInput.addEventListener("input", onInput); 44 | 45 | return () => { 46 | scene.off("play", onPlay); 47 | scene.off("paused", onPaused); 48 | scene.off("animate", onAnimate); 49 | playButton.removeEventListener("click", onClick); 50 | progressInput.removeEventListener("input", onInput); 51 | }; 52 | }, [sceneRef]); 53 | return
54 |
55 | 56 |
; 57 | } -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/HTMLRecorder.ts: -------------------------------------------------------------------------------- 1 | import Recorder, { RecorderOptions } from "@scenejs/recorder"; 2 | import { toBlob } from "html-to-image"; 3 | // import domtoimage from "dom-to-image-more"; 4 | import { Animator } from "scenejs"; 5 | 6 | 7 | export class HTMLRecorder extends Recorder { 8 | protected _el!: HTMLElement; 9 | constructor(options: RecorderOptions = {}) { 10 | super(options); 11 | 12 | this.setCapturing("png", e => { 13 | this._animator.setTime(e.time, true); 14 | return toBlob(this._el, { 15 | pixelRatio: 2, 16 | // bgcolor: "#fff", 17 | backgroundColor: "#fff", 18 | skipFonts: true, 19 | style: { 20 | position: "relative", 21 | left: "0px", 22 | top: "0px", 23 | right: "auto", 24 | bottom: "auto", 25 | margin: "0", 26 | }, 27 | filter(node: HTMLElement) { 28 | // console.log(node); 29 | return true; 30 | } 31 | }); 32 | }); 33 | } 34 | public setElement(el: HTMLElement) { 35 | this._el = el; 36 | } 37 | public async recordElement(animator: Animator, el: HTMLElement) { 38 | try { 39 | this.setAnimator(animator); 40 | this.setElement(el); 41 | 42 | const data = await this.record(); 43 | const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' })); 44 | 45 | return url; 46 | } catch (e) { 47 | console.error(e); 48 | } 49 | return ""; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/MotionEffect/MotionEffect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { EASE_OUT } from "scenejs"; 4 | import { ReactSceneResult, useScene } from "react-scenejs"; 5 | 6 | export const MotionEffect = React.forwardRef((_, ref) => { 7 | const scene = useScene(() => ({ 8 | ".circle1": { 9 | 0: { 10 | "border-width": "70px", 11 | "transform": "scale(0)", 12 | }, 13 | 1: { 14 | "border-width": "0px", 15 | "transform": "scale(1.5)", 16 | }, 17 | 2: 1, 18 | }, 19 | ".triangle": { 20 | 0: { 21 | transform: "rotate(0deg) translate(0px) scale(0.5)", 22 | opacity: 1, 23 | }, 24 | 1.5: { 25 | transform: "rotate(40deg) translate(100px) scale(1)", 26 | opacity: 0, 27 | }, 28 | }, 29 | ".rectangle1": { 30 | 0: { 31 | opacity: 1, 32 | transform: "rotate(0deg) translate(0px) scale(0.3)", 33 | }, 34 | 1.5: { 35 | transform: "rotate(-40deg) translate(-100px) scale(0.9)", 36 | opacity: 0, 37 | }, 38 | }, 39 | ".rectangle2": { 40 | 0: { 41 | transform: " translate(0px, 0px) rotate(0deg) scale(0.3)", 42 | opacity: 1, 43 | }, 44 | 1.5: { 45 | transform: "translate(100px, -100px) rotate(70deg) scale(0.7)", 46 | opacity: 0, 47 | }, 48 | }, 49 | ".circle2": { 50 | 0: { 51 | transform: " translate(0px, 0px) scale(0.7)", 52 | opacity: 1, 53 | }, 54 | 1.5: { 55 | transform: "translate(-100px, -50px) scale(1)", 56 | opacity: 0, 57 | }, 58 | }, 59 | ".star1": { 60 | 0: { 61 | transform: "translateY(0px) rotate(0deg) scale(0.5)", 62 | opacity: 1, 63 | }, 64 | 1.5: { 65 | transform: "translateY(-100px) rotate(90deg) scale(1)", 66 | opacity: 0, 67 | } 68 | } 69 | }), { 70 | easing: EASE_OUT, 71 | fillMode: "forwards", 72 | selector: true, 73 | }); 74 | 75 | React.useImperativeHandle(ref, () => scene, []); 76 | 77 | return
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
; 93 | }); 94 | 95 | MotionEffect.displayName = "MotionEffect"; -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/RenderModal.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { atom, useStoreState, useStoreStateValue, useStoreValue } from "@scena/react-store"; 4 | import { useEffect, useState } from "react"; 5 | import { ReactSceneResult } from "react-scenejs"; 6 | import { HTMLRecorder } from "./HTMLRecorder"; 7 | import { createTimer } from "@scenejs/recorder"; 8 | 9 | 10 | export const $recorder = atom(null); 11 | export const $scene = atom(false); 12 | export const $container = atom(null); 13 | 14 | export function RenderModal() { 15 | 16 | const recorder = React.useMemo(() => new HTMLRecorder(), []); 17 | 18 | useStoreValue($recorder, recorder); 19 | const sceneStore = useStoreValue($scene); 20 | const [scene, setScene] = useStoreState($scene); 21 | const container = useStoreStateValue($container); 22 | const [description, setDescription] = useState("Loading"); 23 | const [leftTime, setLeftTime] = useState(0); 24 | const [ratio, setRatio] = useState(0); 25 | 26 | useEffect(() => { 27 | if (scene) { 28 | const timer = createTimer(); 29 | setLeftTime(0); 30 | setDescription("Loading.."); 31 | 32 | recorder.on("capture", e => { 33 | const nextRatio = e.ratio / 3 * 2; 34 | const info = timer.getCurrentInfo(nextRatio); 35 | 36 | setDescription(`Capturing Frame ${e.frameCount} / ${e.totalFrame}`); 37 | setLeftTime(info.expectedTime - info.currentTime); 38 | setRatio(nextRatio); 39 | }); 40 | recorder.on("processVideo", e => { 41 | const nextRatio = 2 / 3 + e.ratio / 3; 42 | const info = timer.getCurrentInfo(nextRatio); 43 | 44 | setDescription(`Processing: ${e.ratio * 100}%`); 45 | setLeftTime(info.expectedTime - info.currentTime); 46 | setRatio(nextRatio); 47 | }); 48 | recorder.on("processVideoEnd", () => { 49 | setDescription("End Rendering"); 50 | }); 51 | 52 | scene.setTime(0); 53 | recorder.recordElement(scene as any, container.querySelector(".example-container")!).then(url => { 54 | const result = container.querySelector(".result")!; 55 | 56 | result.style.display = "block"; 57 | result.querySelector("video")!.src = url; 58 | 59 | setScene(false); 60 | }).catch(() => { 61 | }); 62 | } else { 63 | try { 64 | recorder.off(); 65 | recorder.exit(); 66 | } catch (e) { 67 | 68 | } 69 | } 70 | return () => { 71 | try { 72 | recorder.off(); 73 | recorder.exit(); 74 | } catch (e) { 75 | 76 | } 77 | }; 78 | }, [scene]); 79 | 80 | return
83 |
84 |

Rendering

85 |

{description}

86 |
87 |
90 |
91 |

Estimated Time Left: {leftTime.toFixed(3)}s

92 |

98 |
99 |
; 100 | } -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/SmokeTransition/SmokeTransition.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { ReactSceneResult, useScene } from "react-scenejs"; 4 | import { selectorAll } from "scenejs"; 5 | 6 | export const SmokeTransition = React.forwardRef((_, ref) => { 7 | const scene = useScene({ 8 | ".smoke-transition-container .circle": { 9 | 0: { 10 | opacity: 1, 11 | }, 12 | }, 13 | }, { 14 | selector: true, 15 | }); 16 | 17 | React.useImperativeHandle(ref, () => scene, []); 18 | const circles: JSX.Element[] = []; 19 | 20 | for (let i = 0; i <= 10; ++i) { 21 | for (let j = 0; j <= 5; ++j) { 22 | let x = i * 80 + (Math.random() - 0.5) * 20; 23 | let y = -j * 80 + (Math.random() - 0.5) * 20; 24 | 25 | circles.push(
); 28 | 29 | if (i <= 1) { 30 | x += -Math.exp((2 - j) / 2) * 100; 31 | y = -j * 20 + (Math.random() - 0.5) * 20; 32 | circles.push(
); 35 | } 36 | } 37 | } 38 | return
39 | {/*
Scene 1
40 |
Scene 2
*/} 41 |
42 |
45 | {circles} 46 |
47 |
50 | {circles} 51 |
52 |
53 |
; 54 | }); 55 | 56 | SmokeTransition.displayName = "SmokeTransition"; 57 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/SquareTransition/SquareTransition.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import * as React from "react"; 3 | import { ReactSceneResult, useScene } from "react-scenejs"; 4 | import { selectorAll } from "scenejs"; 5 | 6 | export const SquareTransition = React.forwardRef((_, ref) => { 7 | const scene = useScene({ 8 | ".square-transition-container .scene1": { 9 | 0: { 10 | opacity: 1, 11 | }, 12 | 0.1: { 13 | opacity: 0, 14 | }, 15 | options: { 16 | delay: 2, 17 | }, 18 | }, 19 | ".square-transition-container .scene2": { 20 | 0: { 21 | opacity: 0, 22 | }, 23 | 0.1: { 24 | opacity: 1, 25 | }, 26 | options: { 27 | easing: "ease-in-out", 28 | delay: 2, 29 | }, 30 | }, 31 | ".square-transition-container .square": selectorAll(i => ({ 32 | 0: { 33 | opacity: 0, 34 | transform: { 35 | scale: 0, 36 | }, 37 | }, 38 | 0.5: { 39 | opacity: 1, 40 | }, 41 | 1: { 42 | transform: { 43 | scale: 1, 44 | } 45 | }, 46 | 1.2: { 47 | transform: { 48 | scale: 1, 49 | }, 50 | }, 51 | 2: { 52 | opacity: 1, 53 | }, 54 | 3: { 55 | opacity: 0, 56 | transform: { 57 | scale: 0, 58 | }, 59 | }, 60 | options: { 61 | easing: "ease-in-out", 62 | delay: 1 + (i % 6) * 0.1 + Math.floor(i / 6) * 0.2, 63 | }, 64 | }), 21), 65 | }, { 66 | selector: true, 67 | }); 68 | 69 | React.useImperativeHandle(ref, () => scene, []); 70 | return
71 |
Scene 1
72 |
Scene 2
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
; 100 | }); 101 | 102 | SquareTransition.displayName = "SquareTransition"; 103 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/global.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "dom-to-image-more" { 3 | import domToImage = require('dom-to-image'); 4 | export = domToImage; 5 | } 6 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | module.exports = function (app) { 2 | app.use(function (req, res, next) { 3 | res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); 4 | res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); 5 | next(); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /packages/recorder-react-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/recorder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.15.0](https://github.com/daybrush/scenejs-render/compare/@scenejs/recorder@0.15.0...@scenejs/recorder@0.15.0) (2023-06-19) 7 | 8 | **Note:** Version bump only for package @scenejs/recorder 9 | 10 | 11 | 12 | 13 | 14 | ## 0.15.0 (2023-01-19) 15 | 16 | 17 | ### :rocket: New Features 18 | 19 | * add recorder ([d84e97a](https://github.com/daybrush/scenejs-render/commit/d84e97a737a08f1f0b39a6cc49b68ad6bfc9b7e6)) 20 | * add recorder events ([a01b5b6](https://github.com/daybrush/scenejs-render/commit/a01b5b6ce0b72c5bfdbb5eee5e6b4448b46474c8)) 21 | * add setAnimator options ([048102b](https://github.com/daybrush/scenejs-render/commit/048102b1d2f220367d4262e7aaeffe42141858a1)) 22 | * add state ([3fbe8e6](https://github.com/daybrush/scenejs-render/commit/3fbe8e6ac868d22b7e60fc6f78531aa6fc464020)) 23 | * setFetchFile ([4a394d8](https://github.com/daybrush/scenejs-render/commit/4a394d86fcdda6bf1d76492a2b40db900c8fd374)) 24 | 25 | 26 | ### :bug: Bug Fix 27 | 28 | * fix amix volume #24 ([944b55f](https://github.com/daybrush/scenejs-render/commit/944b55fe32da9ac3149347aa5d7e043e6c19386e)) 29 | * fix Recorder accessor ([b9a735a](https://github.com/daybrush/scenejs-render/commit/b9a735a338efe9002f6f68fae04a3db97f03a3bf)) 30 | 31 | 32 | ### :memo: Documentation 33 | 34 | * add docs ([5d27ee9](https://github.com/daybrush/scenejs-render/commit/5d27ee9604af7c84ad824e1a10f9219ac7222989)) 35 | * fix README ([25e0795](https://github.com/daybrush/scenejs-render/commit/25e0795c00f1d87bdf6c6625e2f1856052b6f9b6)) 36 | * fix README ([9dbae78](https://github.com/daybrush/scenejs-render/commit/9dbae78daaecc0ae08948f56ca44efd83fd911c7)) 37 | * fix README ([9d368bf](https://github.com/daybrush/scenejs-render/commit/9d368bff00c679181d5a902c3daeb818a257911d)) 38 | 39 | 40 | ### :mega: Other 41 | 42 | * publish packages ([551ae6d](https://github.com/daybrush/scenejs-render/commit/551ae6d9b65c77dcd85307fc8c8eab9ae0129703)) 43 | * publish packages ([6278907](https://github.com/daybrush/scenejs-render/commit/6278907961dea2fbce948d83e437c2abcd313f79)) 44 | * publish packages ([2eb076e](https://github.com/daybrush/scenejs-render/commit/2eb076ec637830a8a6b226d4999d7fd2b6c41146)) 45 | -------------------------------------------------------------------------------- /packages/recorder/README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

Scene.js Recorder

4 |

5 | npm version 6 | 7 | 8 |

9 | 10 | 11 |

🎬 Make a movie of CSS animation through Scene.js

12 | 13 | 14 |

Official Site  /  API  /  Scene.js  /  Main Project

15 |
16 | 17 | Through the module, you can record by specifying the capture method manually and create a file manually through the data. 18 | 19 | ## ⚙️ Installation 20 | ```bash 21 | $ npm install @scenejs/recorder 22 | ``` 23 | To run it locally, add `@ffmpeg/core` to devDependencies. 24 | 25 | ```bash 26 | npm install @ffmpeg/core -D 27 | ``` 28 | 29 | ## 🚀 Examples 30 | * [Scene.js Recorder Browser Demo](https://scenejs-render-demo.netlify.app) 31 | * [Scene.js Recorder Browser Source](https://github.com/daybrush/scenejs-render/tree/master/packages/recorder-react-demo) 32 | 33 | ## 🎬 How to use 34 | #### Browser 35 | 36 | Since `@ffmpeg/ffmpeg` is used, please refer to the document https://github.com/ffmpegwasm/ffmpeg.wasm. 37 | 38 | Or, using a script tag in the browser (only works in some browsers, see list below): 39 | 40 | > SharedArrayBuffer is only available to pages that are [cross-origin isolated](https://developer.chrome.com/blog/enabling-shared-array-buffer/#cross-origin-isolation). So you need to host [your own server](https://github.com/ffmpegwasm/ffmpegwasm.github.io/blob/main/server/server.js) with `Cross-Origin-Embedder-Policy: require-corp` and `Cross-Origin-Opener-Policy: same-origin` headers to use ffmpeg.wasm. 41 | 42 | 43 | > Only browsers with SharedArrayBuffer support can use ffmpeg.wasm, you can check [HERE](https://caniuse.com/sharedarraybuffer) for the complete list. 44 | 45 | 46 | ```ts 47 | import Recorder, { OnRequestCapture } from "@scenjs/recorder"; 48 | import Scene from "scenejs"; 49 | 50 | const scene = new Scene(); 51 | const recorder = new Recorder(); 52 | 53 | recorder.setAnimator(scene); 54 | recorder.setCapturing("png", (e: OnRequestCapture) => { 55 | scene.setTime(e.time, true); 56 | // html to image 57 | return htmlToImage(element); 58 | }); 59 | 60 | recorder.record().then(data => { 61 | const url = URL.createObjectURL(new Blob( 62 | [data.buffer], 63 | { type: 'video/mp4' }, 64 | )); 65 | 66 | video.setAttribute("src", url); 67 | recorder.destroy(); 68 | }); 69 | ``` 70 | 71 | #### Node 72 | 73 | > Since `@scenejs/recorder` is a raw version of `@scenejs/render`, capturing and file creation are not performed. 74 | If you want a completed Recorder, use [`@scenejs/render`](https://github.com/daybrush/scenejs-render/tree/master/packages/render). 75 | 76 | 77 | ```js 78 | const Recorder = require("@scenejs/recorder"); 79 | const fs = require('fs'); 80 | const { Animator } = require("scenejs"); 81 | 82 | const recorder = new Recorder(); 83 | const animator = new Animator({ 84 | duration: 2, 85 | }); 86 | 87 | 88 | recorder.setAnimator(animator); 89 | recorder.setRecording("png", e => { 90 | return `./frame${e.frame}.png`; 91 | }); 92 | recorder.record().then(data => { 93 | fs.writeFileSync("output.mp4", output); 94 | recorder.destroy(); 95 | }); 96 | ``` 97 | 98 | 99 | ## ⭐️ Show Your Support 100 | Please give a ⭐️ if this project helped you! 101 | 102 | 103 | ## 👏 Contributing 104 | 105 | If you have any questions or requests or want to contribute to `scenejs-render` or other packages, please write the [issue](https://github.com/daybrush/scenejs-render/issues) or give me a Pull Request freely. 106 | 107 | 108 | ### Code Contributors 109 | 110 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 111 | 112 | 113 | 114 | 115 | 116 | 117 | ## Sponsors 118 |

119 | 120 | 121 | 122 |

123 | 124 | 125 | ## 🐞 Bug Report 126 | 127 | If you find a bug, please report to us opening a new [Issue](https://github.com/daybrush/scenejs-render/issues) on GitHub. 128 | 129 | 130 | 131 | ## 📝 License 132 | 133 | This project is [MIT](https://github.com/daybrush/scenejs-render/blob/master/LICENSE) licensed. 134 | 135 | ``` 136 | MIT License 137 | 138 | Copyright (c) 2016 Daybrush 139 | 140 | Permission is hereby granted, free of charge, to any person obtaining a copy 141 | of this software and associated documentation files (the "Software"), to deal 142 | in the Software without restriction, including without limitation the rights 143 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 144 | copies of the Software, and to permit persons to whom the Software is 145 | furnished to do so, subject to the following conditions: 146 | 147 | The above copyright notice and this permission notice shall be included in all 148 | copies or substantial portions of the Software. 149 | 150 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 151 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 152 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 153 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 154 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 155 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 156 | SOFTWARE. 157 | ``` 158 | -------------------------------------------------------------------------------- /packages/recorder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scenejs/recorder", 3 | "version": "0.15.0", 4 | "description": "Make a movie of CSS animation through Scene.js for browser", 5 | "main": "./dist/recorder.cjs.js", 6 | "module": "./dist/recorder.esm.js", 7 | "types": "declaration/index.d.ts", 8 | "files": [ 9 | "./*", 10 | "dist/*", 11 | "declaration/*" 12 | ], 13 | "dependencies": { 14 | "@ffmpeg/ffmpeg": "^0.11.6", 15 | "@scena/event-emitter": "^1.0.5", 16 | "tslib": "^2.4.1" 17 | }, 18 | "keywords": [ 19 | "scene", 20 | "scenejs", 21 | "scene.js", 22 | "animate", 23 | "animation", 24 | "css", 25 | "requestAnimationFrame", 26 | "motion", 27 | "media", 28 | "render", 29 | "fps", 30 | "puppeteer", 31 | "ffmpeg" 32 | ], 33 | "devDependencies": { 34 | "@daybrush/builder": "^0.2.0", 35 | "@ffmpeg/core": "^0.11.0", 36 | "@scenejs/media": "^0.2.1", 37 | "@scenejs/timeline": "^0.2.1", 38 | "@types/jest": "^24.0.13", 39 | "@types/node": "^18.11.18", 40 | "ffprobe": "^1.1.2", 41 | "ffprobe-static": "^3.1.0", 42 | "get-video-duration": "^4.1.0", 43 | "gh-pages": "^4.0.0", 44 | "jest": "^24.8.0", 45 | "scenejs": "^1.9.4", 46 | "ts-jest": "^24.0.2", 47 | "tslint": "^5.18.0", 48 | "typescript": "^4.5.0 <4.6.0" 49 | }, 50 | "scripts": { 51 | "build": "rollup -c && tsc -p tsconfig.declaration.json", 52 | "test": "npm run build && node ./test" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/daybrush/scenejs-render.git" 57 | }, 58 | "author": "Daybrush", 59 | "license": "MIT", 60 | "bugs": { 61 | "url": "https://github.com/daybrush/scenejs-render/issues" 62 | }, 63 | "homepage": "https://github.com/daybrush/scenejs-render#readme" 64 | } 65 | -------------------------------------------------------------------------------- /packages/recorder/rollup.config.js: -------------------------------------------------------------------------------- 1 | 2 | const builder = require("@daybrush/builder"); 3 | 4 | 5 | module.exports = builder([ 6 | { 7 | input: "src/index.ts", 8 | output: "./dist/recorder.esm.js", 9 | exports: "named", 10 | format: "es", 11 | }, 12 | { 13 | input: "src/index.cjs.ts", 14 | output: "./dist/recorder.cjs.js", 15 | exports: "named", 16 | format: "cjs", 17 | }, 18 | // { 19 | // input: "src/index.umd.ts", 20 | // output: "./dist/recorder.js", 21 | // exports: "named", 22 | // format: "umd", 23 | // resolve: true, 24 | // commonjs: true, 25 | // }, 26 | ]); 27 | -------------------------------------------------------------------------------- /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 | 8 | 9 | export const DEFAULT_CODECS = { 10 | mp4: "libx264", 11 | webm: "libvpx-vp9", 12 | }; 13 | 14 | /** 15 | * A recorder that captures the screen and creates a video or audio file 16 | * @example 17 | import Recorder, { OnRequestCapture } from "@scenjs/recorder"; 18 | import Scene from "scenejs"; 19 | 20 | const scene = new Scene(); 21 | const recorder = new Recorder(); 22 | 23 | recorder.setAnimator(scene); 24 | recorder.setCapturing("png", (e: OnRequestCapture) => { 25 | scene.setTime(e.time, true); 26 | // html to image 27 | return htmlToImage(element); 28 | }); 29 | 30 | recorder.record().then(data => { 31 | const url = URL.createObjectURL(new Blob( 32 | [data.buffer], 33 | { type: 'video/mp4' }, 34 | )); 35 | 36 | video.setAttribute("src", url); 37 | recorder.destroy(); 38 | }); 39 | */ 40 | export class Recorder extends EventEmitter<{ 41 | captureStart: OnCaptureStart; 42 | capture: OnCapture; 43 | captureEnd: {}; 44 | processVideoStart: Required; 45 | processVideo: OnProcess; 46 | processVideoEnd: {}; 47 | processAudioStart: OnProcessAudioStart; 48 | processAudio: OnProcess; 49 | processAudioEnd: {}; 50 | }> { 51 | protected _animator!: AnimatorLike; 52 | protected _imageType!: "jpeg" | "png"; 53 | protected _ffmpeg!: FFmpeg; 54 | protected _ready!: Promise; 55 | protected _hasMedia!: boolean; 56 | protected _fetchFile: (data: FileType) => Promise = fetchFile; 57 | protected _capturing!: (e: OnRequestCapture) => Promise | FileType; 58 | public recordState: "initial" | "loading" | "capture" | "process" = "initial"; 59 | 60 | /** 61 | * 62 | */ 63 | constructor(protected _options: RecorderOptions = {}) { 64 | super(); 65 | } 66 | /** 67 | * Set up a function to import files. Defaults to fetchData from `@ffmpeg/ffmpeg` 68 | * @sort 1 69 | */ 70 | public setFetchFile(fetchFile: (data: FileType) => Promise) { 71 | this._fetchFile = fetchFile; 72 | } 73 | /** 74 | * Set the function to get the image to be captured per frame. 75 | * @sort 1 76 | * @param - image extension of the file 77 | * @param - A function that returns the image to be captured per frame. 78 | */ 79 | public setCapturing( 80 | imageType: "jpeg" | "png", 81 | capturing: (e: OnRequestCapture) => Promise | FileType, 82 | ) { 83 | this._imageType = imageType; 84 | this._capturing = capturing; 85 | } 86 | /** 87 | * Set the animator to record. 88 | * @sort 1 89 | */ 90 | public setAnimator(animator: AnimatorLike | Partial) { 91 | this._animator 92 | = isAnimatorLike(animator) 93 | ? animator 94 | : new Animator(animator); 95 | } 96 | /** 97 | * Get the result of audio processing. 98 | * @sort 1 99 | */ 100 | public getAudioFile(): Uint8Array { 101 | return this._ffmpeg.FS("readFile", "merge.mp3"); 102 | } 103 | /** 104 | * Start audio processing. 105 | * @sort 1 106 | * @param mediaInfo - media info 107 | * @param options - media info options 108 | * @returns {$ts:Promise} 109 | */ 110 | public async recordMedia(mediaInfo: MediaSceneInfo, options?: RenderMediaInfoOptions) { 111 | let length = 0; 112 | const medias = mediaInfo.medias; 113 | const duration = mediaInfo.duration; 114 | 115 | if (!duration || !medias) { 116 | return; 117 | } 118 | const ffmpeg = await this.init(); 119 | 120 | await medias.reduce(async (pipe, media) => { 121 | await pipe; 122 | const url = media.url; 123 | const seek = media.seek; 124 | const delay = media.delay; 125 | const playSpeed = media.playSpeed; 126 | const volume = media.volume; 127 | 128 | const path = hasProtocol(url) ? url : resolvePath(options?.inputPath ?? "", url); 129 | const [startTime, endTime] = seek; 130 | const fileName = path.match(/[^/]+$/g)?.[0] ?? path; 131 | 132 | 133 | await this.writeFile(fileName, path); 134 | await ffmpeg.run( 135 | "-ss", `${startTime}`, 136 | "-to", `${endTime}`, 137 | "-i", fileName, 138 | "-filter:a", `adelay=${delay * playSpeed * 1000}|${delay * playSpeed * 1000},atempo=${playSpeed},volume=${volume}`, 139 | `audio${length++}.mp3`, 140 | ); 141 | }, Promise.resolve()); 142 | 143 | if (!length) { 144 | return; 145 | } 146 | 147 | const files = ffmpeg.FS("readdir", "./"); 148 | const audios = files.filter(fileName => fileName.match(/audio[\d]+.mp3/)); 149 | const audiosLength = audios.length; 150 | 151 | if (!audiosLength) { 152 | return; 153 | } 154 | const inputOption: string[] = []; 155 | const timer = createTimer(); 156 | 157 | /** 158 | * The event is fired when audio process starts. 159 | * @memberof Recorder 160 | * @event processAudioStart 161 | * @param {Recorder.OnProcessAudioStart} - Parameters for the `processAudioStart` event 162 | */ 163 | this.emit("processAudioStart", { 164 | audiosLength, 165 | }); 166 | audios.forEach(fileName => { 167 | inputOption.push("-i", fileName); 168 | }); 169 | ffmpeg.setProgress(e => { 170 | const ratio = e.ratio; 171 | const { 172 | currentTime, 173 | expectedTime, 174 | } = timer.getCurrentInfo(e.ratio); 175 | /** 176 | * The event is fired when audio processing is in progress. 177 | * @memberof Recorder 178 | * @event processAudio 179 | * @param {Recorder.OnProcess} - Parameters for the `processAudio` event 180 | */ 181 | this.emit("processAudio", { 182 | currentProcessingTime: currentTime, 183 | expectedProcessingTime: expectedTime, 184 | ratio, 185 | }); 186 | }); 187 | await ffmpeg.run( 188 | ...inputOption, 189 | "-filter_complex", `amix=inputs=${audiosLength}:duration=longest:dropout_transition=1000,volume=${audiosLength}`, 190 | "merge.mp3", 191 | ); 192 | /** 193 | * The event is fired when audio process ends. 194 | * @memberof Recorder 195 | * @event processAudioEnd 196 | */ 197 | this.emit("processAudioEnd"); 198 | if (ffmpeg.FS("readdir", "./").indexOf("merge.mp3") >= 0) { 199 | this._hasMedia = true; 200 | 201 | return this.getAudioFile(); 202 | } 203 | } 204 | /** 205 | * Start capturing and video processing. 206 | * @sort 1 207 | * @param options - record options 208 | * @returns {$ts:Promise} 209 | */ 210 | public async record(options: RenderVideoOptions & RecordInfoOptions = {}) { 211 | 212 | const recordInfo = this.getRecordInfo(options); 213 | 214 | const rootStartFrame = recordInfo.startFrame; 215 | const rootEndFrame = recordInfo.endFrame; 216 | const imageType = this._imageType; 217 | const totalFrame = rootEndFrame - rootStartFrame + 1; 218 | const fps = options.fps || 60; 219 | let frameCount = 0; 220 | 221 | this.recordState = "loading"; 222 | await this.init(); 223 | 224 | const timer = createTimer(); 225 | /** 226 | * The event is fired when capture starts. 227 | * @memberof Recorder 228 | * @event captureStart 229 | * @param {Recorder.OnCaptureStart} - Parameters for the `captureStart` event 230 | */ 231 | this.emit("captureStart", { 232 | startFame: rootStartFrame, 233 | endFrame: rootEndFrame, 234 | startTime: recordInfo.startTime, 235 | endTime: recordInfo.endTime, 236 | duration: recordInfo.duation, 237 | multi: options.multi || 1, 238 | imageType, 239 | fps, 240 | }); 241 | 242 | this.recordState = "capture"; 243 | await Promise.all(recordInfo.loops.map((loop, workerIndex) => { 244 | let pipe = Promise.resolve(); 245 | const startFrame = loop.startFrame; 246 | const endFrame = loop.endFrame; 247 | 248 | for (let i = startFrame; i <= endFrame; ++i) { 249 | const callback = ((currentFrame: number) => { 250 | return async () => { 251 | const time = currentFrame / fps; 252 | const data = await this._capturing({ 253 | workerIndex, 254 | frame: currentFrame, 255 | time, 256 | }); 257 | 258 | await this.writeFile(`frame${currentFrame - rootStartFrame}.${imageType}`, data); 259 | ++frameCount; 260 | 261 | const ratio = frameCount / totalFrame; 262 | const { 263 | currentTime: currentCapturingTime, 264 | expectedTime: expectedCapturingTime, 265 | } = timer.getCurrentInfo(ratio); 266 | /** 267 | * The event is fired when frame capturing is in progress. 268 | * @memberof Recorder 269 | * @event capture 270 | * @param {Recorder.OnCapture} - Parameters for the `capture` event 271 | */ 272 | this.emit("capture", { 273 | ratio, 274 | frameCount, 275 | totalFrame, 276 | frameInfo: { 277 | frame: currentFrame, 278 | time, 279 | }, 280 | currentCapturingTime, 281 | expectedCapturingTime, 282 | }); 283 | }; 284 | })(i); 285 | pipe = pipe.then(callback); 286 | } 287 | return pipe; 288 | })); 289 | 290 | /** 291 | * The event is fired when capture ends. 292 | * @memberof Recorder 293 | * @event captureEnd 294 | */ 295 | this.emit("captureEnd"); 296 | return await this.renderVideo({ 297 | ...options, 298 | duration: recordInfo.duation, 299 | }); 300 | } 301 | /** 302 | * Get the information to be recorded through options. 303 | * @sort 1 304 | */ 305 | public getRecordInfo(options: RecordInfoOptions) { 306 | const animator = this._animator; 307 | const inputIteration = options.iteration; 308 | const inputDuration = options.duration || 0; 309 | const inputStartTime = options.startTime || 0; 310 | const inputFPS = options.fps || 60; 311 | const inputMulti = options.multi || 1; 312 | const sceneIterationCount = inputIteration || animator.getIterationCount(); 313 | const sceneDelay = animator.getDelay(); 314 | const playSpeed = animator.getPlaySpeed(); 315 | const duration = animator.getDuration(); 316 | let iterationCount = 0; 317 | 318 | if (sceneIterationCount === "infinite") { 319 | iterationCount = inputIteration || 1; 320 | } else { 321 | iterationCount = inputIteration || sceneIterationCount; 322 | } 323 | const totalDuration = sceneDelay + duration * (iterationCount); 324 | const endTime = inputDuration > 0 325 | ? Math.min(inputStartTime + inputDuration, totalDuration) 326 | : totalDuration; 327 | const startTime = Math.min(inputStartTime, endTime); 328 | const startFrame = Math.floor(startTime * inputFPS / playSpeed); 329 | const endFrame = Math.ceil(endTime * inputFPS / playSpeed); 330 | const dist = Math.ceil((endFrame - startFrame) / (inputMulti || 1)); 331 | const loops: Array<{ 332 | startFrame: number; 333 | endFrame: number; 334 | }> = []; 335 | 336 | for (let i = 0; i < inputMulti; ++i) { 337 | loops.push({ 338 | startFrame: startFrame + dist * i + (i === 0 ? 0 : 1), 339 | endFrame: startFrame + dist * (i + 1), 340 | }); 341 | } 342 | 343 | return { 344 | duation: (endTime - startTime) / playSpeed, 345 | loops, 346 | iterationCount, 347 | startTime, 348 | endTime, 349 | startFrame, 350 | endFrame, 351 | } 352 | } 353 | public async init() { 354 | this._ffmpeg = this._ffmpeg || createFFmpeg({ log: this._options.log }); 355 | 356 | const ffmpeg = this._ffmpeg; 357 | 358 | 359 | if (!this._ready) { 360 | this._ready = ffmpeg.load(); 361 | } 362 | 363 | await this._ready; 364 | return ffmpeg; 365 | } 366 | public async writeFile(fileName: string, file: string | Buffer | File | Blob) { 367 | await this.init(); 368 | 369 | const data = await this._fetchFile(file); 370 | 371 | if (!data) { 372 | return; 373 | } 374 | this._ffmpeg.FS("writeFile", fileName, data); 375 | } 376 | public async renderVideo(options: RenderVideoOptions) { 377 | const { 378 | ext = "mp4", 379 | fps = 60, 380 | codec, 381 | duration, 382 | bitrate: bitrateOption, 383 | cpuUsed, 384 | } = options; 385 | 386 | const hasMedia = this._hasMedia; 387 | const parsedCodec = codec || DEFAULT_CODECS[ext || "mp4"] || DEFAULT_CODECS.mp4; 388 | const bitrate = bitrateOption || "4096k"; 389 | const inputOption = [ 390 | "-i", `frame%d.${this._imageType}`, 391 | ]; 392 | const audioOutputOpion: string[] = []; 393 | const outputOption = [ 394 | `-cpu-used`, `${cpuUsed || 8}`, 395 | "-pix_fmt", "yuva420p", 396 | ]; 397 | 398 | 399 | if (ext === "webm") { 400 | outputOption.push( 401 | "-row-mt", "1", 402 | ); 403 | } 404 | if (hasMedia) { 405 | inputOption.push( 406 | "-i", "merge.mp3", 407 | ); 408 | audioOutputOpion.push( 409 | "-acodec", "aac", 410 | // audio bitrate 411 | '-b:a', "128k", 412 | // audio channels 413 | "-ac", "2", 414 | ); 415 | } 416 | 417 | const ffmpeg = await this.init(); 418 | 419 | 420 | this.recordState = "process"; 421 | const timer = createTimer(); 422 | /** 423 | * The event is fired when process video starts. 424 | * @memberof Recorder 425 | * @event processVideoStart 426 | * @param {Recorder.OnProcessVideoStart} - Parameters for the `processVideoStart` event 427 | */ 428 | this.emit("processVideoStart", { 429 | ext, 430 | fps, 431 | codec: parsedCodec, 432 | duration, 433 | bitrate, 434 | cpuUsed, 435 | }); 436 | ffmpeg.setProgress(e => { 437 | const ratio = e.ratio; 438 | const { 439 | currentTime, 440 | expectedTime, 441 | } = timer.getCurrentInfo(e.ratio); 442 | /** 443 | * The event is fired when frame video processing is in progress. 444 | * @memberof Recorder 445 | * @event processVideo 446 | * @param {Recorder.OnProcess} - Parameters for the `processVideo` event 447 | */ 448 | this.emit("processVideo", { 449 | currentProcessingTime: currentTime, 450 | expectedProcessingTime: expectedTime, 451 | ratio, 452 | }); 453 | }); 454 | 455 | await ffmpeg!.run( 456 | `-r`, `${fps}`, 457 | ...inputOption, 458 | ...audioOutputOpion, 459 | `-c:v`, parsedCodec, 460 | `-loop`, `1`, 461 | `-t`, `${duration}`, 462 | "-y", 463 | `-b:v`, bitrate, 464 | ...outputOption, 465 | `output.${ext}`, 466 | ); 467 | /** 468 | * The event is fired when process video ends 469 | * @memberof Recorder 470 | * @event processVideoEnd 471 | */ 472 | this.emit("processVideoEnd"); 473 | this.recordState = "initial"; 474 | return ffmpeg!.FS('readFile', `output.${ext}`); 475 | } 476 | /** 477 | * Quit ffmpeg. 478 | * @sort 1 479 | */ 480 | public exit() { 481 | try { 482 | this.recordState = "initial"; 483 | this._ready = null; 484 | this._ffmpeg?.exit(); 485 | } catch (e) { 486 | 487 | } 488 | this._ffmpeg = null; 489 | } 490 | /** 491 | * Remove the recorder and ffmpeg instance. 492 | * @sort 1 493 | */ 494 | public destroy() { 495 | this.off(); 496 | this.exit(); 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /packages/recorder/src/index.cjs.ts: -------------------------------------------------------------------------------- 1 | import Recorder, * as modules from "./index"; 2 | 3 | for (const name in modules) { 4 | Recorder[name] = modules[name]; 5 | } 6 | export default Recorder; 7 | module.exports = Recorder; 8 | -------------------------------------------------------------------------------- /packages/recorder/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Recorder } from "./Recorder"; 2 | 3 | export * from "./types"; 4 | export default Recorder; 5 | export { createTimer } from "./utils"; 6 | -------------------------------------------------------------------------------- /packages/recorder/src/index.umd.ts: -------------------------------------------------------------------------------- 1 | import Recorder, * as modules from "./index"; 2 | 3 | for (const name in modules) { 4 | Recorder[name] = modules[name]; 5 | } 6 | export default Recorder; 7 | -------------------------------------------------------------------------------- /packages/recorder/src/types.ts: -------------------------------------------------------------------------------- 1 | import { IterationCountType } from "scenejs"; 2 | 3 | /** 4 | * @memberof Recorder 5 | * @typedef 6 | */ 7 | export type FileType = string | Buffer | File | Blob | null; 8 | 9 | /** 10 | * @memberof Recorder 11 | * @typedef 12 | */ 13 | export interface RenderMediaInfoOptions { 14 | /** 15 | * 16 | */ 17 | inputPath?: string; 18 | } 19 | 20 | /** 21 | * @memberof Recorder 22 | * @typedef 23 | */ 24 | export interface RecorderOptions { 25 | /** 26 | * Whether to show ffmpeg's log 27 | */ 28 | log?: boolean; 29 | } 30 | 31 | /** 32 | * @memberof Recorder 33 | * @typedef 34 | */ 35 | export interface RenderVideoOptions { 36 | /** 37 | * custom scene's duration 38 | * @default scene's duration 39 | */ 40 | duration?: number; 41 | /** 42 | * fps 43 | * @default 60 44 | */ 45 | fps?: number; 46 | /** 47 | * Codec to encode video If you don't set it up, it's the default (mp4: libx264, webm: libvpx-vp9) 48 | * @default "libx264" 49 | */ 50 | codec?: string; 51 | /** 52 | * Bitrate of video (the higher the bit rate, the clearer the video quality) 53 | * @default "4096k" 54 | */ 55 | bitrate?: string; 56 | /** 57 | * file extension 58 | * @default "mp4" 59 | */ 60 | ext?: "mp4" | "webm"; 61 | /** 62 | * Number of cpus to use for ffmpeg video or audio processing 63 | * @default 8 64 | */ 65 | cpuUsed?: number; 66 | } 67 | 68 | /** 69 | * @memberof Recorder 70 | * @typedef 71 | */ 72 | export interface RecordInfoOptions { 73 | /** 74 | * Input iterationCount of the Scene set by the user himself 75 | * @default 0 76 | */ 77 | iteration?: number; 78 | /** 79 | * input how many seconds to play 80 | */ 81 | duration?: number; 82 | /** 83 | * Input for start time 84 | * @default 0 85 | */ 86 | startTime?: number; 87 | /** 88 | * @default 60 89 | */ 90 | fps?: number; 91 | /** 92 | * @default 1 93 | */ 94 | multi?: number; 95 | } 96 | 97 | /** 98 | * @memberof Recorder 99 | * @typedef 100 | */ 101 | export interface OnRequestCapture { 102 | /** 103 | * caputring frame 104 | */ 105 | frame: number; 106 | /** 107 | * capturing time 108 | */ 109 | time: number; 110 | /** 111 | * worker index 112 | */ 113 | workerIndex: number; 114 | } 115 | 116 | /** 117 | * @memberof Recorder 118 | * @typedef 119 | */ 120 | export interface OnProcessAudioStart { 121 | /** 122 | * Number of audios included in mediaInfo 123 | */ 124 | audiosLength: number; 125 | } 126 | 127 | /** 128 | * @memberof Recorder 129 | * @typedef 130 | */ 131 | export interface OnCaptureStart { 132 | /** 133 | * The starting frame of the animator to capture. 134 | */ 135 | startFame: number; 136 | /** 137 | * The end frame of the animator to capture. 138 | */ 139 | endFrame: number; 140 | /** 141 | * The starting time of the animator to capture. 142 | */ 143 | startTime: number; 144 | /** 145 | * The end time of the animator to capture. 146 | */ 147 | endTime: number; 148 | /** 149 | * Length of time of the animator to capture 150 | */ 151 | duration: number; 152 | /** 153 | * The number of split captures. 154 | */ 155 | multi: number; 156 | /** 157 | * fps 158 | */ 159 | fps: number; 160 | /** 161 | * Image type (or extension) to capture 162 | */ 163 | imageType: "png" | "jpeg"; 164 | } 165 | 166 | /** 167 | * @memberof Recorder 168 | * @typedef 169 | */ 170 | export interface OnCapture { 171 | /** 172 | * Progress rate captured 173 | */ 174 | ratio: number; 175 | /** 176 | * Number of frames captured 177 | */ 178 | frameCount: number; 179 | /** 180 | * Total number of frames to capture 181 | */ 182 | totalFrame: number; 183 | /** 184 | * Current capturing progress time 185 | */ 186 | currentCapturingTime: number; 187 | /** 188 | * Expected time for all capturing. 189 | */ 190 | expectedCapturingTime: number; 191 | /** 192 | * Frame Information Captured Now 193 | */ 194 | frameInfo: { frame: number; time: number; }; 195 | } 196 | 197 | /** 198 | * @memberof Recorder 199 | * @typedef 200 | */ 201 | export interface OnProcess { 202 | /** 203 | * Current progress percentage 204 | */ 205 | ratio: number; 206 | /** 207 | * Current processing progress time 208 | */ 209 | currentProcessingTime: number; 210 | /** 211 | * Expected time for all processing. 212 | */ 213 | expectedProcessingTime: number; 214 | } 215 | 216 | export interface AnimatorLike { 217 | getIterationCount(): IterationCountType; 218 | getDelay(): number; 219 | getDuration(): number; 220 | getPlaySpeed(): number; 221 | setTime(time: number | string, isTick?: boolean): any; 222 | } 223 | -------------------------------------------------------------------------------- /packages/recorder/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Animator } from "scenejs"; 2 | import { AnimatorLike } from "./types"; 3 | 4 | export function hasProtocol(url) { 5 | try { 6 | const protocol = new URL(url).protocol; 7 | 8 | if (protocol) { 9 | return true; 10 | } 11 | } catch (e) { 12 | } 13 | return false; 14 | } 15 | export function resolvePath(path1: string, path2: string) { 16 | let paths = path1.split("/").slice(0, -1).concat(path2.split("/")); 17 | 18 | paths = paths.filter((directory, i) => { 19 | return i === 0 || directory !== "."; 20 | }); 21 | 22 | let index = -1; 23 | 24 | // tslint:disable-next-line: no-conditional-assignment 25 | while ((index = paths.indexOf("..")) > 0) { 26 | paths.splice(index - 1, 2); 27 | } 28 | return paths.join("/"); 29 | } 30 | 31 | 32 | export function createTimer() { 33 | const startTime = Date.now(); 34 | 35 | return { 36 | startTime, 37 | getCurrentInfo(ratio: number) { 38 | const currentTime = (Date.now() - startTime) / 1000; 39 | 40 | return { 41 | currentTime, 42 | expectedTime: (currentTime / ratio), 43 | }; 44 | } 45 | } 46 | } 47 | 48 | export function isAnimatorLike(value: any): value is AnimatorLike { 49 | return value && "getDuration" in value; 50 | } 51 | -------------------------------------------------------------------------------- /packages/recorder/test.js: -------------------------------------------------------------------------------- 1 | const { fstat } = require("fs"); 2 | const Recorder = require("./dist/recorder.cjs"); 3 | const fs = require('fs'); 4 | const { Animator } = require("scenejs"); 5 | 6 | const animator = new Animator({ 7 | duration: 2, 8 | }); 9 | const recorder = new Recorder(); 10 | 11 | recorder.setAnimator(animator); 12 | recorder.setRecording("png", e => { 13 | return `./.scene_ch/frame${e.frame}.png`; 14 | }); 15 | 16 | (async () => { 17 | const output = await recorder.record({ 18 | duration: 2, 19 | fps: 60, 20 | ext: "mp4", 21 | }); 22 | fs.writeFileSync("output.mp4", output); 23 | recorder.destroy(); 24 | })(); 25 | -------------------------------------------------------------------------------- /packages/recorder/test2.js: -------------------------------------------------------------------------------- 1 | const ffprobe = require('ffprobe'); 2 | const ffprobeStatic = require('ffprobe-static'); 3 | 4 | ffprobe('./output.mp4', { path: ffprobeStatic.path }).then(info => { 5 | console.log(info); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/recorder/tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "removeComments": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "declarationDir": "declaration" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/recorder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./outjs/", 4 | "esModuleInterop": false, 5 | "sourceMap": true, 6 | "module": "es2015", 7 | "target": "es5", 8 | "experimentalDecorators": true, 9 | "skipLibCheck": true, 10 | "allowSyntheticDefaultImports": true, 11 | "moduleResolution": "node", 12 | "lib": [ 13 | "es2015", 14 | "dom" 15 | ], 16 | }, 17 | "include": [ 18 | "./src/**/*.ts" 19 | ] 20 | } -------------------------------------------------------------------------------- /packages/render/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [{*.js,*.ts}] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [{package.json,.travis.yml}] 14 | indent_style = space 15 | indent_size = 4 -------------------------------------------------------------------------------- /packages/render/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.DS_Store 3 | .DS_Store 4 | doc/ 5 | dist/ 6 | packages/*/dist 7 | demo/dist/ 8 | release/ 9 | npm-debug.log* 10 | coverage/ 11 | jsdoc/ 12 | doc/ 13 | outjs/ 14 | declaration/ 15 | build/ 16 | .vscode/ 17 | rollup-plugin-visualizer/ 18 | statistics/ 19 | .scene_cache 20 | *.mp4 21 | *.webm 22 | ffmpeg -------------------------------------------------------------------------------- /packages/render/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.DS_Store 3 | .DS_Store 4 | doc/ 5 | template/ 6 | example/ 7 | karma.conf.js 8 | test/ 9 | mocha.opts 10 | Gruntfile.js 11 | webpack.*.js 12 | .travis.yml 13 | packages 14 | release/ 15 | demo/ 16 | coverage/ 17 | dist/report.html 18 | rollup-plugin-visualizer/ 19 | outjs/ 20 | statistics/ 21 | .scene_cache 22 | .scene_ch 23 | *.mp3 24 | *.mp4 25 | *.webm 26 | ffmpeg -------------------------------------------------------------------------------- /packages/render/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.16.0](https://github.com/daybrush/scenejs-render/compare/@scenejs/render@0.15.0...@scenejs/render@0.16.0) (2023-06-19) 7 | 8 | 9 | ### :rocket: New Features 10 | 11 | * add logger, noLog, created options #30 ([fd7f5cb](https://github.com/daybrush/scenejs-render/commit/fd7f5cb5948d025193676f44a62b72dca7ab41bd)) 12 | 13 | 14 | ### :bug: Bug Fix 15 | 16 | * fix default value #27 ([96b42f7](https://github.com/daybrush/scenejs-render/commit/96b42f70d9bc72195491080c4c5ae3e8000e1a7d)) 17 | 18 | 19 | ### :memo: Documentation 20 | 21 | * fix README ([287bdda](https://github.com/daybrush/scenejs-render/commit/287bddabc010854c9ad5f3d8fbe294a70f11b2e3)) 22 | * fix README ([be8c291](https://github.com/daybrush/scenejs-render/commit/be8c291f5d0de37d3975afad79ea85679cc0c17d)) 23 | * fix README for node 18 #29 ([ee14b58](https://github.com/daybrush/scenejs-render/commit/ee14b581d940fe98e921f41eb9d41cd55d746c21)) 24 | * fix types ([ebc4f62](https://github.com/daybrush/scenejs-render/commit/ebc4f627253916779ed33357f2aa763473bea469)) 25 | 26 | 27 | ### :mega: Other 28 | 29 | * bump render version ([d5bf670](https://github.com/daybrush/scenejs-render/commit/d5bf6702d4508a80f2c67a3d87bad8fdc7a874ae)) 30 | * update version ([d1e5d49](https://github.com/daybrush/scenejs-render/commit/d1e5d49e204757f8a35e1c092c92962dc59402ff)) 31 | 32 | 33 | 34 | ## 0.15.0 (2023-01-19) 35 | 36 | 37 | ### :rocket: New Features 38 | 39 | * add ffmpegPath option ([fe3f91b](https://github.com/daybrush/scenejs-render/commit/fe3f91bc11fa99d386f8f27cf2bf15d2f09e34a1)) 40 | * add recorder demo ([4e64808](https://github.com/daybrush/scenejs-render/commit/4e648080b4dd6d13232ad1fe1b388073289b510a)) 41 | * add setAnimator options ([048102b](https://github.com/daybrush/scenejs-render/commit/048102b1d2f220367d4262e7aaeffe42141858a1)) 42 | * setFetchFile ([4a394d8](https://github.com/daybrush/scenejs-render/commit/4a394d86fcdda6bf1d76492a2b40db900c8fd374)) 43 | * use recorder ([b6a9f6d](https://github.com/daybrush/scenejs-render/commit/b6a9f6d919d14563fdebfcbe844dea507c064b7d)) 44 | 45 | 46 | ### :memo: Documentation 47 | 48 | * fix README ([25e0795](https://github.com/daybrush/scenejs-render/commit/25e0795c00f1d87bdf6c6625e2f1856052b6f9b6)) 49 | * fix README ([9dbae78](https://github.com/daybrush/scenejs-render/commit/9dbae78daaecc0ae08948f56ca44efd83fd911c7)) 50 | * fix README ([9d368bf](https://github.com/daybrush/scenejs-render/commit/9d368bff00c679181d5a902c3daeb818a257911d)) 51 | * move paths ([aa10170](https://github.com/daybrush/scenejs-render/commit/aa10170927c415edfa2f0f519a2f71c2449355fc)) 52 | 53 | 54 | ### :mega: Other 55 | 56 | * publish packages ([551ae6d](https://github.com/daybrush/scenejs-render/commit/551ae6d9b65c77dcd85307fc8c8eab9ae0129703)) 57 | * publish packages ([6278907](https://github.com/daybrush/scenejs-render/commit/6278907961dea2fbce948d83e437c2abcd313f79)) 58 | * publish packages ([2eb076e](https://github.com/daybrush/scenejs-render/commit/2eb076ec637830a8a6b226d4999d7fd2b6c41146)) 59 | -------------------------------------------------------------------------------- /packages/render/README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 |

Scene.js Render

4 |

5 | npm version 6 | 7 | 8 |

9 | 10 | 11 |

🎬 Make a movie of CSS animation through Scene.js

12 | 13 |

Official Site  /  API  /  Scene.js  /  Main Project

14 |
15 | 16 | It reads a file from Node through a command and records (capture and video process) to create a video (or audio) file. 17 | 18 | > On `Node 18`, the following error occurs. (`TypeError: Failed to parse URL from .../@ffmpeg/core/dist/ffmpeg-core.wasm. Build with -s ASSERTIONS=1 for more info.`) 19 | > 20 | > Use a version below `Node 18` or `--ffmpegPath`. 21 | 22 | 23 | ## ⚙️ Installation 24 | 25 | ```bash 26 | $ npm install @scenejs/render 27 | ``` 28 | 29 | ## 🎬 How to use 30 | 31 | ```bash 32 | # basic 33 | $ render -i index.html 34 | ``` 35 | ```bash 36 | $ npx @scenejs/render -i index.html 37 | ``` 38 | 39 | 40 | ```bash 41 | # export mp4 42 | $ render -i index.html --name scene 43 | 44 | # export only mp3 45 | $ render -i index.html --name scene -o output.mp3 46 | 47 | # export mp3 file and mp4 file 48 | $ render -i index.html --name scene -o output.mp3,output.mp4 49 | 50 | # If you want to use native ffmpeg for faster speed, input the path of ffmpeg. 51 | $ render -i index.html --ffmpegPath ./ffmpeg 52 | ``` 53 | 54 | ``` 55 | Usage: render [options] [command] 56 | 57 | Commands: 58 | help Display help 59 | version Display version 60 | 61 | Options: 62 | -a, --alpha If you use the png image type, you can create a video with a transparent background. (The video extension must be webm.) (defaults to 0) 63 | -B, --bitrate [value] Bitrate of video (the higher the bit rate, the clearer the video quality) (defaults to "4096k") 64 | -b, --buffer Whether to use buffer instead of saving frame image file in capturing (cache is disabled.) (defaults to 0) 65 | -c, --cache If there are frames in the cache folder, the capture process is passed. (defaults to 0) 66 | -C, --cacheFolder [value] Cache folder name to save frame image (defaults to ".scene_cache") 67 | -C, --codec Codec to encode video If you don't set it up, it's the default(mp4: libx264, webm:libvpx-vp9) (defaults to "") 68 | -C, --cpuUsed Number of cpus to use for ffmpeg video or audio processing (defaults to 8) 69 | -d, --duration Input how many seconds to play (defaults to 0) 70 | -F, --ffmpegLog Whether to show ffmpeg's logs (disabled by default) 71 | -F, --ffmpegPath If you want to use native ffmpeg for faster speed, input the path of ffmpeg. (defaults to "") 72 | -f, --fps fps (defaults to 60) 73 | -h, --height Video height to render (defaults to 1080) 74 | -H, --help Output usage information 75 | -I, --imageType [value] Image type to record video (png or jpeg) (defaults to "png") 76 | -i, --input [value] File URL to Rendering (defaults to "index.html") 77 | -I, --iteration Input iterationCount of the Scene set by the user himself. (defaults to 0) 78 | -m, --media [value] Name of mediaScene to render (defaults to "mediaScene") 79 | -M, --multi Number of browsers to be used for capturing (defaults to 1) 80 | -n, --name [value] the global variable name of the Scene, SceneItem, and Animator instance that will play the animation. (defaults to "scene") 81 | -N, --noLog Whether to Scene.js Render's logs (disabled by default) 82 | -o, --output [value] Output file name (defaults to "output.mp4") 83 | -r, --referer The Referer request header contains the address of the previous web page from which a link to the currently requested page was followed. (defaults to "") 84 | -s, --scale Scale of screen size (defaults to 1) 85 | -S, --startTime Input for start time (defaults to 0) 86 | -v, --version Output the version number 87 | -w, --width Video width to render (defaults to 1920) 88 | ``` 89 | 90 | ### Programatically 91 | ```js 92 | const { render } = require("@scenejs/render"); 93 | 94 | render({ 95 | input: "./index.html", 96 | name: "scene", 97 | output: "output.mp4", 98 | }); 99 | ``` 100 | 101 | If you want to access the recorder instance, use the `created` function 102 | 103 | ```js 104 | render({ 105 | input: "./test/test.html", 106 | fps: 60, 107 | ffmpegPath: "./ffmpeg", 108 | cache: true, 109 | noLog: true, 110 | logger: (...messages) => { 111 | console.log(messages); 112 | }, 113 | created: inst => { 114 | inst.on("capture", e => { 115 | console.log(e.ratio); 116 | }); 117 | }, 118 | }); 119 | ``` 120 | 121 | ### Result 122 | ``` 123 | Start Render 124 | Start Workers (startTime: 0, endTime: 2, fps: 60, startFrame: 0, endFrame: 120, workers: 4) 125 | Start Worker 0 126 | Start Worker 1 127 | Start Worker 2 128 | Start Worker 3 129 | Start Capture (startFame: 0, endFrame: 120, startTime: 0, endTime: 2, fps: 60, duration: 2, imageType: png) 130 | Capture Progress: 0.826% (1 / 121) 131 | - Captured Frame: 0 132 | - Current Capturing Time: 0.192s, Expected Capturing Time: 23.232s 133 | ... 134 | Capture Progress: 100% (121 / 121) 135 | - Captured Frame: 30 136 | - Current Capturing Time: 5.545s, Expected Capturing Time: 5.545s 137 | End Capture 138 | Start Video Process (ext: mp4, fps: 60, duration: 2) 139 | Video Processing Progress: 0% 140 | - Current Processing Time: 0.099s, Expected Processing Time: Infinitys 141 | Video Processing Progress: 9.09% 142 | - Current Processing Time: 0.665s, Expected Processing Time: 7.315s 143 | ... 144 | Video Processing Progress: 100% 145 | - Current Processing Time: 6.977s, Expected Processing Time: 6.977s 146 | End Video Process 147 | Created Video: output.mp4 148 | End Render (Rendering Time: 15.747s) 149 | ``` 150 | 151 | 152 | ## ⭐️ Show Your Support 153 | Please give a ⭐️ if this project helped you! 154 | 155 | 156 | ## 👏 Contributing 157 | 158 | If you have any questions or requests or want to contribute to `scenejs-render` or other packages, please write the [issue](https://github.com/daybrush/scenejs-render/issues) or give me a Pull Request freely. 159 | 160 | 161 | ### Code Contributors 162 | 163 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. 164 | 165 | 166 | 167 | 168 | 169 | 170 | ## Sponsors 171 |

172 | 173 | 174 | 175 |

176 | 177 | 178 | ## 🐞 Bug Report 179 | 180 | If you find a bug, please report to us opening a new [Issue](https://github.com/daybrush/scenejs-render/issues) on GitHub. 181 | 182 | 183 | 184 | ## 📝 License 185 | 186 | This project is [MIT](https://github.com/daybrush/scenejs-render/blob/master/LICENSE) licensed. 187 | 188 | ``` 189 | MIT License 190 | 191 | Copyright (c) 2016 Daybrush 192 | 193 | Permission is hereby granted, free of charge, to any person obtaining a copy 194 | of this software and associated documentation files (the "Software"), to deal 195 | in the Software without restriction, including without limitation the rights 196 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 197 | copies of the Software, and to permit persons to whom the Software is 198 | furnished to do so, subject to the following conditions: 199 | 200 | The above copyright notice and this permission notice shall be included in all 201 | copies or substantial portions of the Software. 202 | 203 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 204 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 205 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 206 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 207 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 208 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 209 | SOFTWARE. 210 | ``` 211 | -------------------------------------------------------------------------------- /packages/render/examples/background.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daybrush/scenejs-render/9b7b67e934e74ec3c034b7976e66272b52af53ef/packages/render/examples/background.mp3 -------------------------------------------------------------------------------- /packages/render/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | 88 | 92 | 96 | 100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /packages/render/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const args = require('args'); 4 | const render = require("./dist/render.cjs").render; 5 | 6 | args 7 | .option('input', 'File URL to Rendering', 'index.html') 8 | .option('name', 'the global variable name of the Scene, SceneItem, and Animator instance that will play the animation.', 'scene') 9 | .option('media', 'Name of mediaScene to render', 'mediaScene') 10 | .option('scale', 'Scale of screen size', 1) 11 | .option('fps', 'fps', 60) 12 | .option('width', 'Video width to render', 1920) 13 | .option('height', 'Video height to render', 1080) 14 | .option('output', 'Output file name', 'output.mp4') 15 | .option('startTime', 'Input for start time', 0) 16 | .option('duration', 'Input how many seconds to play', 0) 17 | .option('iteration', 'Input iterationCount of the Scene set by the user himself.', 0) 18 | .option("buffer", "Whether to use buffer instead of saving frame image file in capturing (cache is disabled.)", 0) 19 | .option('cache', 'If there are frames in the cache folder, the capture process is passed.', 0) 20 | .option('cacheFolder', 'Cache folder name to save frame image', ".scene_cache") 21 | .option('multi', 'Number of browsers to be used for capturing', 1) 22 | .option('cpuUsed', 'Number of cpus to use for ffmpeg video or audio processing', 8) 23 | .option("codec", "Codec to encode video If you don't set it up, it's the default(mp4: libx264, webm:libvpx-vp9)", "") 24 | .option("bitrate", "Bitrate of video (the higher the bit rate, the clearer the video quality)", "4096k") 25 | .option("referer", "The Referer request header contains the address of the previous web page from which a link to the currently requested page was followed.", "") 26 | .option("imageType", "Image type to record video (png or jpeg)", "png") 27 | .option("alpha", "If you use the png image type, you can create a video with a transparent background. (The video extension must be webm.)", 0) 28 | .option("ffmpegPath", "If you want to use native ffmpeg for faster speed, input the path of ffmpeg.", "") 29 | .option("ffmpegLog", "Whether to show ffmpeg's logs", false) 30 | .option("noLog", "Whether to Scene.js Render's logs", false); 31 | 32 | 33 | const flags = args.parse(process.argv); 34 | 35 | render(flags); 36 | -------------------------------------------------------------------------------- /packages/render/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "", 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | "testMatch": ["/test/**/*.spec.ts"], 9 | // "testRegex": "spec\\.ts$", 10 | "moduleFileExtensions": [ 11 | "ts", 12 | "tsx", 13 | "js", 14 | "jsx", 15 | "json", 16 | "node", 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /packages/render/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scenejs/render", 3 | "version": "0.16.0", 4 | "description": "Make a movie of CSS animation through Scene.js for node", 5 | "main": "./dist/render.cjs.js", 6 | "module": "./dist/render.esm.js", 7 | "types": "declaration/index.d.ts", 8 | "files": [ 9 | "./*", 10 | "dist/*", 11 | "declaration/*" 12 | ], 13 | "dependencies": { 14 | "@daybrush/utils": "^1.10.2", 15 | "@ffmpeg/core": "^0.11.0", 16 | "@ffmpeg/ffmpeg": "^0.11.6", 17 | "@scena/event-emitter": "^1.0.5", 18 | "@scenejs/recorder": "~0.15.0", 19 | "@types/puppeteer": "^5.4.5", 20 | "args": "^5.0.1", 21 | "fluent-ffmpeg": "^2.1.2", 22 | "puppeteer": "^19.4.0", 23 | "scenejs": "^1.9.4" 24 | }, 25 | "keywords": [ 26 | "scene", 27 | "scenejs", 28 | "scene.js", 29 | "animate", 30 | "animation", 31 | "css", 32 | "requestAnimationFrame", 33 | "motion", 34 | "media", 35 | "render", 36 | "fps", 37 | "puppeteer", 38 | "ffmpeg" 39 | ], 40 | "devDependencies": { 41 | "@daybrush/builder": "^0.2.0", 42 | "@scenejs/media": "^0.2.1", 43 | "@scenejs/timeline": "^0.2.1", 44 | "@types/fluent-ffmpeg": "^2.1.20", 45 | "@types/jest": "^24.0.13", 46 | "get-video-duration": "^4.1.0", 47 | "jest": "^24.8.0", 48 | "ts-jest": "^24.0.2", 49 | "typescript": "^4.5.0 <4.6.0" 50 | }, 51 | "scripts": { 52 | "build": "rollup -c && rm -rf declaration && tsc -p tsconfig.declaration.json", 53 | "test": "jest --watchAll", 54 | "test1": "rollup -c && node ./index.js -i ./test/test.html --fps 60", 55 | "test:prog": "rollup -c && node ./test/test.js", 56 | "test:ffmpeg": "rollup -c && node ./index.js -i ./test/test.html --fps 60 -c 1 --cacheFolder .scene_ch --ffmpegPath ./ffmpeg", 57 | "test:nolog": "rollup -c && node ./index.js -i ./test/test.html --fps 60 -c 1 --cacheFolder .scene_ch --ffmpegPath ./ffmpeg -N", 58 | "test:cache": "rollup -c && node ./index.js -i ./test/test.html --fps 60 --multi 4 -c 1 --cacheFolder .scene_ch", 59 | "test:alpha": "rollup -c && node ./index.js -i ./test/test.html --fps 60 -c 1 --alpha 1 --output output.webm", 60 | "test2": "rollup -c && node ./index.js -i ./test/test2.html --fps 30", 61 | "test3": "rollup -c && node ./index.js -i ./index/index.html --fps 30 --duration 12 --width 678 --height 508 --output output.webm ", 62 | "test4": "rollup -c && node ./index.js -i ././test/test.html -d 1 -f 25 -o", 63 | "test:multi": "rollup -c && node ./index.js -i ./test/test.html --fps 60 --multi 4", 64 | "test:start": "rollup -c && node ./index.js -i ./test/test.html -S 1 --fps 60 --multi 4", 65 | "test:examples": "rollup -c && node ./index.js -i ./examples/index.html --fps 60 --multi 4 -c 1 --imageType jpeg", 66 | "help": "node ./index.js --help", 67 | "demo:deploy-init": "gh-pages -d demo/ --remote origin" 68 | }, 69 | "bin": { 70 | "render": "./index.js" 71 | }, 72 | "repository": { 73 | "type": "git", 74 | "url": "git+https://github.com/daybrush/scenejs-render.git" 75 | }, 76 | "author": "Daybrush", 77 | "license": "MIT", 78 | "bugs": { 79 | "url": "https://github.com/daybrush/scenejs-render/issues" 80 | }, 81 | "homepage": "https://github.com/daybrush/scenejs-render#readme" 82 | } 83 | -------------------------------------------------------------------------------- /packages/render/rollup.config.js: -------------------------------------------------------------------------------- 1 | 2 | const builder = require("@daybrush/builder"); 3 | 4 | const external = { 5 | "scenejs": "Scene", 6 | }; 7 | 8 | module.exports = builder([ 9 | { 10 | input: "src/index.ts", 11 | output: "./dist/render.esm.js", 12 | exports: "named", 13 | format: "es", 14 | }, 15 | { 16 | input: "src/index.ts", 17 | output: "./dist/render.cjs.js", 18 | exports: "named", 19 | format: "cjs", 20 | }, 21 | ]); 22 | -------------------------------------------------------------------------------- /packages/render/src/BinaryRecorder.ts: -------------------------------------------------------------------------------- 1 | import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | import { RecorderOptions, RecordInfoOptions, RenderVideoOptions } from "@scenejs/recorder"; 5 | import { FFmpeg, FSMethodArgs, FSMethodNames, LogCallback, ProgressCallback } from "@ffmpeg/ffmpeg"; 6 | import { RenderRecorder } from "./RenderRecorder"; 7 | import { Logger } from "./Logger"; 8 | import { RenderRecorderOptions } from "./types"; 9 | 10 | // Binary 11 | // [ 12 | // '-i', 13 | // '.scene_ch/frame%d.png', 14 | // '-y', 15 | // '-r', 16 | // '60', 17 | // '-c:v', 18 | // 'libx264', 19 | // '-loop', 20 | // '1', 21 | // '-t', 22 | // '2', 23 | // '-y', 24 | // '-b:v', 25 | // '4096k', 26 | // '-cpu-used', 27 | // '8', 28 | // '-pix_fmt', 29 | // 'yuva420p', 30 | // '.scene_ch/output.mp4' 31 | // ] 32 | // Audio 33 | // "-ss", `${startTime}`, 34 | // "-to", `${endTime}`, 35 | // "-i", fileName, 36 | // "-filter:a", `adelay=${delay * playSpeed * 1000}|${delay * playSpeed * 1000},atempo=${playSpeed},volume=${volume}`, 37 | // `audio${length++}.mp3`, 38 | 39 | // "-filter_complex", `amix=inputs=${audiosLength}:duration=longest:dropout_transition=1000,volume=${audiosLength}`, 40 | 41 | // audioOutputOpion.push( 42 | // "-acodec", "aac", 43 | // // audio bitrate 44 | // '-b:a', "128k", 45 | // // audio channels 46 | // "-ac", "2", 47 | // ); 48 | 49 | interface BinaryRecorderOptions extends RenderRecorderOptions { 50 | ffmpegPath: string; 51 | cacheFolder: string; 52 | } 53 | 54 | function initBinaryFFMpeg( 55 | ffmpegPath: string, 56 | cacheFolder: string, 57 | totalCountRef: { current: number }, 58 | logger: Logger, 59 | ): FFmpeg { 60 | 61 | let fluentFfmpeg: FfmpegCommand | null = null; 62 | function initFfmpeg() { 63 | if (!fluentFfmpeg) { 64 | fluentFfmpeg = ffmpeg(); 65 | fluentFfmpeg.setFfmpegPath(ffmpegPath); 66 | } 67 | } 68 | initFfmpeg(); 69 | return { 70 | setProgress(progress: ProgressCallback): void { 71 | initFfmpeg(); 72 | fluentFfmpeg.on("progress", e => { 73 | if ("percent" in e ) { 74 | progress({ ratio: e.percent / 100 }); 75 | } else { 76 | const totalCount = totalCountRef.current; 77 | 78 | progress({ ratio: (e.frames + 1) / totalCount }); 79 | } 80 | }); 81 | }, 82 | run(...args: string[]): Promise { 83 | const nextArgs = [...args]; 84 | const length = nextArgs.length; 85 | 86 | const target = path.resolve(cacheFolder, nextArgs.splice(length - 1, 1)[0]); 87 | 88 | initFfmpeg(); 89 | const mediaInputs: string[] = []; 90 | let hasMedia = false; 91 | let hasVideo = false; 92 | 93 | nextArgs.forEach((arg, i) => { 94 | const value = nextArgs[i + 1]; 95 | if (arg === "-i") { 96 | if (path.extname(value) === ".mp3") { 97 | hasMedia = true; 98 | mediaInputs.push(path.resolve(cacheFolder, value)); 99 | } else { 100 | hasVideo = true; 101 | fluentFfmpeg.addInput(path.resolve(cacheFolder, value)); 102 | } 103 | } 104 | }); 105 | 106 | let fps = 60; 107 | let duration = 0; 108 | let videoBitrate = "4096k"; 109 | let videoCodec = "libx264"; 110 | let pixFmt = "yuva420p"; 111 | let cpuUsed = 4; 112 | 113 | let audioCodec = ""; 114 | let audioBitrate = ""; 115 | let audioChannels = 0; 116 | let seekInput = 0; 117 | let to = 0; 118 | let audioFilter: string[] = []; 119 | let filterComplex = ""; 120 | 121 | nextArgs.forEach((arg, i) => { 122 | const value = nextArgs[i + 1]; 123 | 124 | if (arg === "-r") { 125 | fps = parseFloat(value); 126 | } else if (arg === "-c:v") { 127 | videoCodec = value; 128 | } else if (arg === "-t") { 129 | duration = parseFloat(value); 130 | fluentFfmpeg.loop(value); 131 | } else if (arg === "-b:v") { 132 | videoBitrate = value; 133 | } else if (arg === "-pix_fmt") { 134 | pixFmt = value; 135 | } else if (arg === "-cpu-used") { 136 | cpuUsed = parseFloat(value); 137 | } else if (arg === "-acodec") { 138 | audioCodec = value; 139 | } else if (arg === "-b:a") { 140 | audioBitrate = value; 141 | } else if (arg === "-ac") { 142 | audioChannels = parseFloat(value); 143 | } else if (arg === "-ss") { 144 | seekInput = parseFloat(value); 145 | } else if (arg === "-to") { 146 | to = parseFloat(value); 147 | } else if (arg === "-filter:a") { 148 | audioFilter = value.split(","); 149 | } else if (arg === "-filter_complex") { 150 | filterComplex = value; 151 | } 152 | }); 153 | 154 | if (hasVideo) { 155 | fluentFfmpeg 156 | .inputFPS(fps) 157 | .loop(duration) 158 | .videoBitrate(videoBitrate) 159 | .videoCodec(videoCodec) 160 | .outputOption([ 161 | "-pix_fmt", pixFmt, 162 | "-cpu-used", `${cpuUsed}`, 163 | ]); 164 | } 165 | if (hasMedia) { 166 | mediaInputs.forEach(input => { 167 | fluentFfmpeg.addInput(input); 168 | }); 169 | if (seekInput) { 170 | fluentFfmpeg.seekInput(seekInput); 171 | } 172 | if (to) { 173 | fluentFfmpeg.inputOption(["-to", `${to}`]); 174 | } 175 | if (audioFilter.length) { 176 | fluentFfmpeg.audioFilter(audioFilter); 177 | } 178 | if (filterComplex) { 179 | fluentFfmpeg.inputOptions([ 180 | "-filter_complex", filterComplex, 181 | ]); 182 | } 183 | if (audioCodec) { 184 | fluentFfmpeg.audioCodec(audioCodec); 185 | } 186 | if (audioBitrate) { 187 | fluentFfmpeg.audioBitrate(audioBitrate); 188 | } 189 | if (audioChannels) { 190 | fluentFfmpeg.audioChannels(audioChannels); 191 | } 192 | } 193 | if (hasMedia || hasVideo) { 194 | fluentFfmpeg.save(target); 195 | } else { 196 | return Promise.resolve(); 197 | } 198 | 199 | return new Promise(resolve => { 200 | fluentFfmpeg.on("end", () => { 201 | fluentFfmpeg = null; 202 | resolve(); 203 | }).on("error", e => { 204 | logger.log(`Error: ${target}`, e.message); 205 | fluentFfmpeg = null; 206 | resolve(); 207 | }); 208 | }); 209 | }, 210 | FS(method: Method, ...args: FSMethodArgs[Method]): any { 211 | if (method === "readFile") { 212 | return new Uint8Array(fs.readFileSync(path.resolve(cacheFolder, args[0]))); 213 | } else if (method === "readdir") { 214 | return fs.readdirSync(path.resolve(cacheFolder, args[0])) 215 | } else if (method === "writeFile") { 216 | fs.writeFileSync(path.resolve(cacheFolder, args[0]), args[1]); 217 | return Promise.resolve(); 218 | } 219 | return null as any; 220 | }, 221 | setLogger(log: LogCallback){ 222 | return; 223 | }, 224 | setLogging(logging: boolean) { 225 | 226 | }, 227 | exit() { 228 | fluentFfmpeg?.kill("0"); 229 | fluentFfmpeg = null; 230 | }, 231 | async load() { 232 | return; 233 | }, 234 | isLoaded() { 235 | return true; 236 | }, 237 | }; 238 | } 239 | export class BinaryRecorder extends RenderRecorder { 240 | private _totalCountRef = { current: 0 }; 241 | constructor(protected options: BinaryRecorderOptions) { 242 | super(options); 243 | } 244 | public async init() { 245 | if (!this._ready) { 246 | const options = this.options; 247 | this._ffmpeg = initBinaryFFMpeg(options.ffmpegPath, options.cacheFolder, this._totalCountRef, options.logger); 248 | this._ready = Promise.resolve(); 249 | } 250 | return this._ffmpeg; 251 | } 252 | public async writeFile(fileName: string, file: string | Buffer | File | Blob): Promise { 253 | await this.init(); 254 | 255 | const data = await this._fetchFile(file); 256 | 257 | if (!data) { 258 | return; 259 | } 260 | 261 | fs.writeFileSync(path.resolve(this.options.cacheFolder, fileName), data); 262 | } 263 | public async record(options: RenderVideoOptions & RecordInfoOptions = {}) { 264 | this.once("captureStart", e => { 265 | this._totalCountRef.current = e.endFrame - e.startFame + 1; 266 | }); 267 | return super.record(options); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /packages/render/src/Logger.ts: -------------------------------------------------------------------------------- 1 | export class Logger { 2 | constructor( 3 | private _logger?: (...messages: string[]) => void, 4 | private _log?: boolean, 5 | ) {} 6 | log(...messages: string[]) { 7 | this._logger?.(...messages); 8 | 9 | if (!this._log) { 10 | return; 11 | } 12 | console.log(...messages); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/render/src/RenderRecorder.ts: -------------------------------------------------------------------------------- 1 | import Recorder, { RecorderOptions } from "@scenejs/recorder"; 2 | import { ChildWorker, RenderRecorderOptions } from "./types"; 3 | 4 | function toFixed(num: number) { 5 | return parseFloat(num.toFixed(3)); 6 | } 7 | 8 | export class RenderRecorder extends Recorder { 9 | constructor(options: RenderRecorderOptions) { 10 | super(options); 11 | 12 | const logger = options.logger; 13 | 14 | this.on("processVideoStart", e => { 15 | logger.log(`Start Video Process (ext: ${e.ext}, fps: ${e.fps}, duration: ${e.duration})`); 16 | }); 17 | this.on("processVideoEnd", () => { 18 | logger.log("End Video Process"); 19 | }); 20 | this.on("processAudioStart", e => { 21 | logger.log(`Start Audio Process (audiosLength: ${e.audiosLength})`); 22 | }); 23 | this.on("processAudioEnd", () => { 24 | logger.log("End Audio Process"); 25 | }); 26 | this.on("captureStart", e => { 27 | logger.log(`Start Capture (startFame: ${e.startFame}, endFrame: ${e.endFrame}, startTime: ${e.startTime}, endTime: ${e.endTime}, fps: ${e.fps}, duration: ${e.duration}, imageType: ${e.imageType})`); 28 | }); 29 | this.on("captureEnd", () => { 30 | logger.log("End Capture"); 31 | }); 32 | this.on("capture", e => { 33 | logger.log( 34 | `Capture Progress: ${toFixed(e.frameCount / e.totalFrame * 100)}% (${e.frameCount} / ${e.totalFrame})` 35 | + `\n- Captured Frame: ${e.frameInfo.frame}` 36 | + `\n- Current Capturing Time: ${toFixed(e.currentCapturingTime)}s, Expected Capturing Time: ${toFixed(e.expectedCapturingTime)}s` 37 | ); 38 | }); 39 | this.on("processAudio", e => { 40 | logger.log( 41 | `Audio Preocessing Progress: ${toFixed(e.ratio * 100)}%` 42 | + `\n- Current processing Time: ${toFixed(e.currentProcessingTime)}s, Expected Preocessing Time: ${toFixed(e.expectedProcessingTime)}s` 43 | ); 44 | }); 45 | this.on("processVideo", e => { 46 | logger.log( 47 | `Video Processing Progress: ${toFixed(e.ratio * 100)}%` 48 | + `\n- Current Processing Time: ${toFixed(e.currentProcessingTime)}s, Expected Processing Time: ${toFixed(e.expectedProcessingTime)}s` 49 | ); 50 | }); 51 | } 52 | public setRenderCapturing( 53 | imageType: "png" | "jpeg", 54 | workers: ChildWorker[], 55 | isCache?: boolean, 56 | cacheFolder?: string, 57 | ) { 58 | if (isCache) { 59 | this.setCapturing(imageType, async e => { 60 | return `./${cacheFolder}/frame${e.frame}.${imageType}`; 61 | }); 62 | } else { 63 | this.setCapturing(imageType, async e => { 64 | const fileName = await workers[e.workerIndex].record({ 65 | frame: e.frame, 66 | }); 67 | 68 | return fileName; 69 | }); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/render/src/child.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from "puppeteer"; 2 | import type { Page, Browser } from "puppeteer"; 3 | import { openPage } from "./utils"; 4 | import { ChildOptions, RecordOptions, ChildWorker } from "./types"; 5 | 6 | 7 | export function getTimeFunction(options: ChildOptions, time: number) { 8 | const { 9 | hasOnlyMedia, 10 | hasMedia, 11 | name, 12 | delay, 13 | media, 14 | } = options; 15 | return new Function(` 16 | if (${!hasOnlyMedia}) { 17 | ${name}.setTime(${time - delay}, true); 18 | } 19 | if (${!!hasMedia}) { 20 | ${media}.setTime(${time}); 21 | } 22 | var scenes = []; 23 | 24 | function forEach(item) { 25 | if (!item) { 26 | return; 27 | } 28 | // MediaScene 29 | if ("getMediaItem" in item) { 30 | // Media 31 | var element = item.getMediaItem().getElements()[0]; 32 | 33 | if (element && element.seeking) { 34 | scenes.push(new Promise(function (resolve) { 35 | element.addEventListener("seeked", function () { 36 | resolve(); 37 | }, { 38 | once: true, 39 | }); 40 | })); 41 | } 42 | return; 43 | } 44 | // Scene 45 | if ("getItem" in item && "forEach" in item) { 46 | item.forEach(forEach); 47 | } 48 | } 49 | forEach(${name}); 50 | return Promise.all(scenes);`); 51 | } 52 | 53 | export async function recordChild( 54 | page: Page, 55 | childOptions: ChildOptions, 56 | data: RecordOptions, 57 | ) { 58 | const { 59 | imageType, 60 | alpha, 61 | cacheFolder, 62 | playSpeed, 63 | fps, 64 | endTime, 65 | skipFrame, 66 | } = childOptions; 67 | 68 | const frame = data.frame; 69 | const time = Math.min(frame * playSpeed / fps, endTime); 70 | const timeFunction = getTimeFunction(childOptions, time); 71 | 72 | await page.evaluate(timeFunction as any); 73 | 74 | const fileName = `./${cacheFolder}/frame${frame - skipFrame}.${imageType}`; 75 | 76 | await page.screenshot({ 77 | type: imageType, 78 | path: fileName, 79 | omitBackground: alpha, 80 | }); 81 | 82 | return fileName; 83 | } 84 | 85 | 86 | export function createChildWorker(workerIndex: number): ChildWorker { 87 | let browser!: Browser; 88 | let page!: Page; 89 | let ready!: Promise; 90 | let childOptions!: ChildOptions; 91 | 92 | return { 93 | workerIndex, 94 | async start(options: ChildOptions) { 95 | childOptions = options; 96 | 97 | console.log(`Start Worker ${workerIndex}`); 98 | ready = new Promise(async resolve => { 99 | browser = await puppeteer.launch({ 100 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 101 | }); 102 | page = await openPage(browser, options); 103 | resolve(); 104 | }); 105 | 106 | return ready; 107 | }, 108 | record(options: RecordOptions) { 109 | return recordChild(page, childOptions, options); 110 | }, 111 | disconnect() { 112 | return browser.close(); 113 | }, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /packages/render/src/index.ts: -------------------------------------------------------------------------------- 1 | import render from "./render"; 2 | 3 | export { render }; 4 | -------------------------------------------------------------------------------- /packages/render/src/render.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Page } from "puppeteer"; 2 | import { isLocalFile, openPage, rmdir } from "./utils"; 3 | import * as fs from "fs"; 4 | import { IterationCountType } from "scenejs"; 5 | import { RenderOptions } from "./types"; 6 | import { createTimer } from "@scenejs/recorder"; 7 | import { MediaSceneInfo } from "@scenejs/media"; 8 | import { ChildOptions, ChildWorker, RecordOptions } from "./types"; 9 | import { createChildWorker, recordChild } from "./child"; 10 | import * as pathModule from "path"; 11 | import * as url from "url"; 12 | import { fetchFile } from "@ffmpeg/ffmpeg"; 13 | import { isString } from "@daybrush/utils"; 14 | import { BinaryRecorder } from "./BinaryRecorder"; 15 | import { RenderRecorder } from "./RenderRecorder"; 16 | import { Logger } from "./Logger"; 17 | 18 | async function getMediaInfo(page: Page, media: string) { 19 | if (!media) { 20 | return; 21 | } 22 | try { 23 | return await page.evaluate(`${media}.finish().getInfo()`) as MediaSceneInfo; 24 | } catch (e) { 25 | // 26 | } 27 | 28 | return; 29 | } 30 | 31 | /** 32 | * @namespace Render 33 | */ 34 | /** 35 | * @memberof Render 36 | * @param options 37 | * @return {$ts:Promise} 38 | * @example 39 | import { render } from "@scenejs/render"; 40 | 41 | render({ 42 | input: "./index.html", 43 | name: "scene", 44 | output: "output.mp4", 45 | }); 46 | */ 47 | async function render(options: RenderOptions = {}) { 48 | const { 49 | name = "scene", 50 | media = "mediaScene", 51 | fps = 60, 52 | width = 1920, 53 | height = 1080, 54 | input: inputPath = "./index.html", 55 | output: outputPath = "output.mp4", 56 | startTime: inputStartTime = 0, 57 | duration: inputDuration = 0, 58 | iteration: inputIteration = 0, 59 | scale = 1, 60 | multi = 1, 61 | bitrate = "4096k", 62 | codec, 63 | referer, 64 | imageType = "png", 65 | alpha = 0, 66 | cache, 67 | cacheFolder = ".scene_cache", 68 | cpuUsed, 69 | ffmpegLog, 70 | buffer, 71 | ffmpegPath, 72 | noLog, 73 | created, 74 | logger: externalLogger, 75 | } = options; 76 | let path; 77 | 78 | if (inputPath.match(/https*:\/\//g)) { 79 | path = inputPath; 80 | } else { 81 | path = url.pathToFileURL(pathModule.resolve(process.cwd(), inputPath)).href; 82 | } 83 | const logger = new Logger(externalLogger, !noLog); 84 | const timer = createTimer(); 85 | 86 | logger.log("Start Render"); 87 | const outputs = outputPath.split(","); 88 | const videoOutputs = outputs.filter(file => file.match(/\.(mp4|webm)$/g)); 89 | const isVideo = videoOutputs.length > 0; 90 | const audioPath = outputs.find(file => file.match(/\.mp3$/g)); 91 | const recorder = ffmpegPath ? new BinaryRecorder({ 92 | ffmpegPath, 93 | cacheFolder, 94 | log: !!ffmpegLog, 95 | logger, 96 | }) : new RenderRecorder({ 97 | log: !!ffmpegLog, 98 | logger, 99 | }); 100 | 101 | 102 | // create a Recorder instance and call `created` hook function. 103 | created?.(recorder); 104 | recorder.init(); 105 | 106 | const browser = await puppeteer.launch({ 107 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 108 | }); 109 | 110 | const page = await openPage(browser, { 111 | name, 112 | media, 113 | width, 114 | height, 115 | path, 116 | scale, 117 | referer, 118 | }); 119 | 120 | const mediaInfo = await getMediaInfo(page, media); 121 | const hasMedia = !!mediaInfo; 122 | 123 | let hasOnlyMedia = false; 124 | let iterationCount: IterationCountType; 125 | let delay: number; 126 | let playSpeed: number; 127 | let duration: number; 128 | 129 | try { 130 | iterationCount = inputIteration || await page.evaluate(`${name}.getIterationCount()`) as IterationCountType; 131 | delay = await page.evaluate(`${name}.getDelay()`) as number; 132 | playSpeed = await page.evaluate(`${name}.getPlaySpeed()`) as number; 133 | duration = await page.evaluate(`${name}.getDuration()`) as number; 134 | } catch (e) { 135 | if (hasMedia) { 136 | logger.log("Only Media Scene"); 137 | hasOnlyMedia = true; 138 | iterationCount = 1; 139 | delay = 0; 140 | playSpeed = 1; 141 | duration = mediaInfo.duration; 142 | } else { 143 | throw e; 144 | } 145 | } 146 | 147 | recorder.setAnimator({ 148 | delay, 149 | duration, 150 | iterationCount, 151 | playSpeed, 152 | }); 153 | 154 | const { 155 | startFrame, 156 | startTime, 157 | endFrame, 158 | endTime, 159 | } = recorder.getRecordInfo({ 160 | fps, 161 | startTime: inputStartTime || 0, 162 | iteration: inputIteration || 0, 163 | duration: inputDuration || 0, 164 | multi, 165 | }); 166 | 167 | // Process Cache: Pass Capturing 168 | let isCache = false; 169 | const nextInfo = JSON.stringify({ inputPath, startTime, endTime, fps, startFrame, endFrame, imageType }); 170 | 171 | if (cache) { 172 | try { 173 | const cacheInfo = fs.readFileSync(`./${cacheFolder}/cache.txt`, "utf8"); 174 | 175 | if (cacheInfo === nextInfo) { 176 | isCache = true; 177 | } 178 | } catch (e) { 179 | isCache = false; 180 | } 181 | } 182 | 183 | !isCache && rmdir(`./${cacheFolder}`); 184 | !fs.existsSync(`./${cacheFolder}`) && fs.mkdirSync(`./${cacheFolder}`); 185 | 186 | 187 | if (hasMedia) { 188 | recorder.setFetchFile(data => { 189 | if (isString(data) && isLocalFile(data)) { 190 | let fileName = data; 191 | try { 192 | fileName = new URL(data).pathname; 193 | } catch (e) { } 194 | 195 | return Promise.resolve().then(() => { 196 | return fs.readFileSync(fileName); 197 | }); 198 | } 199 | return fetchFile(data); 200 | }); 201 | 202 | await recorder.recordMedia(mediaInfo, { 203 | inputPath, 204 | }); 205 | } 206 | 207 | if (!isVideo) { 208 | logger.log("No Video"); 209 | 210 | if (audioPath && hasMedia) { 211 | logger.log("Audio File is created") 212 | fs.writeFileSync(audioPath, recorder.getAudioFile()); 213 | } else { 214 | throw new Error("Add Audio Input"); 215 | } 216 | return; 217 | } 218 | 219 | 220 | if (hasMedia) { 221 | fs.writeFileSync(`./${cacheFolder}/merge.mp3`, recorder.getAudioFile()); 222 | } 223 | 224 | 225 | const childOptions: ChildOptions = { 226 | hasOnlyMedia, 227 | name, 228 | media, 229 | path, 230 | width, 231 | height, 232 | scale, 233 | delay, 234 | hasMedia, 235 | referer, 236 | imageType, 237 | alpha: !!alpha, 238 | buffer: !!buffer, 239 | cacheFolder, 240 | playSpeed, 241 | fps, 242 | endTime, 243 | skipFrame: startFrame, 244 | }; 245 | const workers: ChildWorker[] = [ 246 | { 247 | workerIndex: 0, 248 | start() { 249 | logger.log("Start Worker 0"); 250 | return Promise.resolve(); 251 | }, 252 | record(recordOptions: RecordOptions) { 253 | return recordChild( 254 | page, 255 | childOptions, 256 | recordOptions, 257 | ); 258 | }, 259 | disconnect() { 260 | return browser.close(); 261 | } 262 | } 263 | ]; 264 | recorder.setRenderCapturing(imageType, workers, isCache, cacheFolder); 265 | 266 | if (isCache) { 267 | logger.log(`Use Cache (startTime: ${startTime}, endTime: ${endTime}, fps: ${fps}, startFrame: ${startFrame}, endFrame: ${endFrame})`); 268 | } else { 269 | logger.log(`Start Workers (startTime: ${startTime}, endTime: ${endTime}, fps: ${fps}, startFrame: ${startFrame}, endFrame: ${endFrame}, workers: ${multi})`); 270 | 271 | for (let i = 1; i < multi; ++i) { 272 | workers.push(createChildWorker(i)); 273 | } 274 | 275 | await Promise.all(workers.map(worker => worker.start(childOptions))); 276 | } 277 | const ext = pathModule.parse(videoOutputs[0]).ext.replace(/^\./g, "") as "mp4" | "webm"; 278 | 279 | recorder.once("captureEnd", () => { 280 | cache && fs.writeFileSync(`./${cacheFolder}/cache.txt`, nextInfo); 281 | }); 282 | const data = await recorder.record({ 283 | ext, 284 | fps, 285 | startTime: inputStartTime || 0, 286 | iteration: inputIteration || 0, 287 | duration: inputDuration || 0, 288 | multi, 289 | codec, 290 | bitrate, 291 | cpuUsed, 292 | }); 293 | 294 | logger.log(`Created Video: ${outputPath}`); 295 | fs.writeFileSync(outputPath, data); 296 | 297 | !cache && rmdir(cacheFolder); 298 | 299 | await Promise.all(workers.map(worker => worker.disconnect())); 300 | 301 | 302 | recorder.destroy(); 303 | logger.log(`End Render (Rendering Time: ${timer.getCurrentInfo(1).currentTime}s)`); 304 | 305 | return recorder; 306 | } 307 | 308 | 309 | export default render; 310 | -------------------------------------------------------------------------------- /packages/render/src/types.ts: -------------------------------------------------------------------------------- 1 | import Recorder, { RecorderOptions } from "@scenejs/recorder"; 2 | import { Logger } from "./Logger"; 3 | 4 | 5 | export interface RenderRecorderOptions extends RecorderOptions { 6 | logger: Logger; 7 | } 8 | /** 9 | * @typedef 10 | * @memberof Render 11 | */ 12 | export interface RenderOptions { 13 | /** 14 | * File URL to Rendering 15 | * @default "./index.html" 16 | */ 17 | input?: string; 18 | /** 19 | * fps 20 | * @default 60 21 | */ 22 | fps?: number; 23 | /** 24 | * Output file name 25 | * @default "output.mp4" 26 | */ 27 | output?: string; 28 | /** 29 | * Input for start time 30 | * @default 0 31 | */ 32 | startTime?: number; 33 | /** 34 | * Whether to use buffer instead of saving frame image file in capturing (cache is disabled.) 35 | * @default false 36 | */ 37 | buffer?: boolean | number | undefined | ""; 38 | /** 39 | * If there are frames in the cache folder, the capture process is passed. 40 | * @default false 41 | */ 42 | cache?: boolean | number | undefined | ""; 43 | /** 44 | * Cache folder name to save frame image 45 | * @default ".scene_cache" 46 | */ 47 | cacheFolder?: string; 48 | /** 49 | * Number of browsers to be used for capturing 50 | * @default 1 51 | */ 52 | multi?: number; 53 | /** 54 | * Input how many seconds to play 55 | * @default 0 56 | */ 57 | duration?: number | undefined | ""; 58 | /** 59 | * Input iterationCount of the Scene set by the user himself. 60 | * @default 0 61 | */ 62 | iteration?: number | undefined | ""; 63 | /** 64 | * Bitrate of video (the higher the bit rate, the clearer the video quality) 65 | * @default "4096k" 66 | */ 67 | bitrate?: string; 68 | /** 69 | * Codec to encode video If you don't set it up, it's the default 70 | * @default "mp4: libx264, webm:libvpx-vp9" 71 | */ 72 | codec?: string; 73 | /** 74 | * Image type to record video (png or jpeg) 75 | * If png is selected, webm and alpha are available. 76 | * @default "png" 77 | */ 78 | imageType?: "png" | "jpeg"; 79 | /** 80 | * If you use the png image type, you can create a video with a transparent background. (The video extension must be webm.) 81 | * @default false 82 | */ 83 | alpha?: boolean | number | undefined | ""; 84 | /** 85 | * Number of cpus to use for ffmpeg video or audio processing 86 | * @default 8 87 | */ 88 | cpuUsed?: number; 89 | /** 90 | * page width 91 | */ 92 | width?: number; 93 | /** 94 | * page height 95 | */ 96 | height?: number; 97 | /** 98 | * scene's name in window 99 | */ 100 | name?: string; 101 | /** 102 | * scene's media name in window 103 | */ 104 | media?: string; 105 | /** 106 | * file path or url 107 | */ 108 | path?: string; 109 | /** 110 | * page scale 111 | */ 112 | scale?: number; 113 | /** 114 | * browser's referer 115 | */ 116 | referer?: string; 117 | /** 118 | * If you want to use native ffmpeg for faster speed, write the path of ffmpeg. 119 | */ 120 | ffmpegPath?: string; 121 | /** 122 | * Whether to show ffmpeg's logs 123 | * @default false 124 | */ 125 | ffmpegLog?: boolean; 126 | /** 127 | * Whether to show Scene.js's Render logs 128 | * @default true 129 | */ 130 | noLog?: boolean; 131 | /** 132 | * A hook function called when an instance is created 133 | */ 134 | created?: (inst: Recorder) => void; 135 | /** 136 | * Messages output from Scene.js Render can be received from the outside through a function. 137 | */ 138 | logger?: (...messages) => void; 139 | } 140 | 141 | 142 | export type ChildMessage = { 143 | type: "startChild"; 144 | data: ChildOptions; 145 | } | { 146 | type: "record"; 147 | data: RecordOptions; 148 | } 149 | 150 | export interface RecordOptions { 151 | frame: number; 152 | } 153 | 154 | export interface OpenPageOptions { 155 | /** 156 | * page width 157 | */ 158 | width: number; 159 | /** 160 | * page height 161 | */ 162 | height: number; 163 | /** 164 | * scene's name in window 165 | */ 166 | name: string; 167 | /** 168 | * scene's media name in window 169 | */ 170 | media: string; 171 | /** 172 | * file path or url 173 | */ 174 | path: string; 175 | /** 176 | * page scale 177 | */ 178 | scale: number; 179 | /** 180 | * browser's referer 181 | */ 182 | referer: string; 183 | } 184 | 185 | export interface ChildOptions extends OpenPageOptions { 186 | /** 187 | * Whether to use buffer instead of saving frame image file in capturing (cache is disabled.) 188 | */ 189 | buffer: boolean; 190 | /** 191 | * whether to apply alpha 192 | * @default false 193 | */ 194 | alpha: boolean; 195 | /** 196 | * Image type for recording 197 | * alpha can be used. 198 | * @default "png" 199 | */ 200 | imageType: "png" | "jpeg"; 201 | /** 202 | * Whether only MediaScene exists 203 | */ 204 | hasOnlyMedia: boolean; 205 | /** 206 | * scene's start delay 207 | */ 208 | delay: number; 209 | /** 210 | * Whether MediaScene exists 211 | */ 212 | hasMedia: boolean; 213 | /** 214 | * cache folder name 215 | * @default ".scene_cache" 216 | */ 217 | cacheFolder: string; 218 | /** 219 | * scene's play spedd 220 | */ 221 | playSpeed: number; 222 | /** 223 | * fps to capture screen 224 | */ 225 | fps: number; 226 | /** 227 | * the time the scene ends 228 | */ 229 | endTime: number; 230 | /** 231 | * Skip frame number to capture 232 | */ 233 | skipFrame: number; 234 | } 235 | 236 | export interface ChildWorker { 237 | workerIndex: number; 238 | disconnect(): Promise; 239 | start(options: ChildOptions): Promise; 240 | record(options: RecordOptions): Promise; 241 | } 242 | -------------------------------------------------------------------------------- /packages/render/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { Page, Browser } from "puppeteer"; 3 | import { OpenPageOptions } from "./types"; 4 | 5 | export async function openPage(browser: Browser, { 6 | width, 7 | height, 8 | path, 9 | scale, 10 | name, 11 | media, 12 | referer, 13 | }: OpenPageOptions): Promise { 14 | let page = null; 15 | try { 16 | page = await browser.newPage(); 17 | page.setUserAgent(browser.userAgent() + " Scene.js"); 18 | page.setViewport({ 19 | width: width / scale, 20 | height: height / scale, 21 | deviceScaleFactor: scale, 22 | }); 23 | await page.goto(path, { 24 | referer, 25 | }); 26 | } catch (e) { 27 | throw new Error(`Puppeteer Error in opening page.\npath: ${path}, width: ${width}, height: ${height}, scale: ${scale}, referer: ${referer}\n${e.message}`); 28 | } 29 | 30 | const result = await page.evaluate(`(async () => { 31 | const timeout = Date.now() + 10000; 32 | 33 | async function retry() { 34 | if (Date.now() > timeout) { 35 | return false; 36 | } 37 | if (typeof ${name} !== "undefined") { 38 | return true; 39 | } 40 | return await new Promise(resolve => { 41 | setTimeout(async () => { 42 | resolve(retry()); 43 | }, 500); 44 | }) 45 | } 46 | 47 | return await retry(); 48 | })()`); 49 | 50 | if (!result) { 51 | throw new Error("Timeout: 10000ms"); 52 | } 53 | try { 54 | await page.evaluate(`${name}.finish()`); 55 | } catch (e) { 56 | // 57 | } 58 | try { 59 | await page.evaluate(`${media}.finish()`); 60 | } catch (e) { 61 | // 62 | } 63 | return page; 64 | } 65 | 66 | 67 | export function isLocalFile(url: string) { 68 | try { 69 | const protocol = new URL(url).protocol; 70 | 71 | if (!protocol || protocol.startsWith("file")) { 72 | return true; 73 | } else { 74 | return false; 75 | } 76 | } catch(e) { 77 | } 78 | return true; 79 | } 80 | export function hasProtocol(url) { 81 | try { 82 | const protocol = new URL(url).protocol; 83 | 84 | if (protocol) { 85 | return true; 86 | } 87 | } catch(e) { 88 | } 89 | return false; 90 | } 91 | export function resolvePath(path1, path2) { 92 | let paths = path1.split("/").slice(0, -1).concat(path2.split("/")); 93 | 94 | paths = paths.filter((directory, i) => { 95 | return i === 0 || directory !== "."; 96 | }); 97 | 98 | let index = -1; 99 | 100 | // tslint:disable-next-line: no-conditional-assignment 101 | while ((index = paths.indexOf("..")) > 0) { 102 | paths.splice(index - 1, 2); 103 | } 104 | return paths.join("/"); 105 | } 106 | 107 | export function rmdir(path) { 108 | if (fs.existsSync(path)) { 109 | fs.readdirSync(path).forEach(file => { 110 | const currentPath = path + "/" + file; 111 | 112 | if (fs.lstatSync(currentPath).isDirectory()) { // recurse 113 | rmdir(currentPath); 114 | } else { // delete file 115 | fs.unlinkSync(currentPath); 116 | } 117 | }); 118 | fs.rmdirSync(path); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/render/test/test.html: -------------------------------------------------------------------------------- 1 | 105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | 122 | -------------------------------------------------------------------------------- /packages/render/test/test.js: -------------------------------------------------------------------------------- 1 | const { render } = require("../dist/render.cjs"); 2 | 3 | // "test:nolog": "rollup -c && node ./index.js -i ./test/test.html --fps 60 -c 1 --cacheFolder .scene_ch --ffmpegPath ./ffmpeg -N", 4 | render({ 5 | input: "./test/test.html", 6 | fps: 60, 7 | ffmpegPath: "./ffmpeg", 8 | cache: true, 9 | noLog: true, 10 | logger: (...messages) => { 11 | console.log(messages); 12 | }, 13 | created: inst => { 14 | inst.on("capture", e => { 15 | console.log(e.ratio); 16 | }); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/render/test/test2.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 90 | 121 | 122 | 127 | 141 | -------------------------------------------------------------------------------- /packages/render/tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "removeComments": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "declarationDir": "declaration" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/render/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./outjs/", 4 | "esModuleInterop": false, 5 | "sourceMap": true, 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "experimentalDecorators": true, 9 | "skipLibCheck": true, 10 | "allowSyntheticDefaultImports": true, 11 | "moduleResolution": "node", 12 | "lib": [ 13 | "es2015", 14 | "dom" 15 | ], 16 | }, 17 | "include": [ 18 | "./src/**/*.ts" 19 | ] 20 | } -------------------------------------------------------------------------------- /packages/render/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "noImplicitAny": false, 6 | "types": [ 7 | "karma-chai", 8 | "mocha" 9 | ] 10 | }, 11 | "include": [ 12 | "./src/**/*.ts", 13 | "./test/**/*.ts" 14 | ] 15 | } -------------------------------------------------------------------------------- /static/scripts/custom.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | window.dataLayer = window.dataLayer || []; 3 | function gtag() { dataLayer.push(arguments); } 4 | gtag('js', new Date()); 5 | 6 | gtag('config', 'G-TRBNXHQ0ZF'); 7 | var script = document.createElement("script"); 8 | 9 | script.src = "https://www.googletagmanager.com/gtag/js?id=G-TRBNXHQ0ZF"; 10 | 11 | document.body.appendChild(script); 12 | })(); 13 | --------------------------------------------------------------------------------