jbilcke-hf HF staff commited on
Commit
2cae2a9
0 Parent(s):

initial commit 🎬

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +6 -0
  2. .env +3 -0
  3. .gitignore +13 -0
  4. .nvmrc +1 -0
  5. Dockerfile +50 -0
  6. LICENSE.txt +201 -0
  7. README.md +38 -0
  8. package-lock.json +0 -0
  9. package.json +40 -0
  10. src/core/base64/addBase64.mts +51 -0
  11. src/core/base64/dataUriToBlob.mts +15 -0
  12. src/core/base64/extractBase64.mts +36 -0
  13. src/core/clap/getClapAssetSourceType.mts +25 -0
  14. src/core/clap/parseClap.mts +320 -0
  15. src/core/clap/types.mts +203 -0
  16. src/core/converters/blobToWebp.mts +5 -0
  17. src/core/converters/bufferToJpeg.mts +5 -0
  18. src/core/converters/bufferToMp3.mts +5 -0
  19. src/core/converters/bufferToMp4.mts +5 -0
  20. src/core/converters/bufferToPng.mts +5 -0
  21. src/core/converters/bufferToWav.mts +5 -0
  22. src/core/converters/bufferToWebp.mts +5 -0
  23. src/core/converters/convertImageTo.mts +31 -0
  24. src/core/converters/convertImageToJpeg.mts +27 -0
  25. src/core/converters/convertImageToOriginal.mts +6 -0
  26. src/core/converters/convertImageToPng.mts +23 -0
  27. src/core/converters/convertImageToWebp.mts +41 -0
  28. src/core/converters/htmlToBase64Png.mts +78 -0
  29. src/core/converters/imageFormats.mts +1 -0
  30. src/core/ffmpeg/addImageToVideo.mts +50 -0
  31. src/core/ffmpeg/addTextToVideo.mts +23 -0
  32. src/core/ffmpeg/concatenateAudio.mts +122 -0
  33. src/core/ffmpeg/concatenateVideos.mts +61 -0
  34. src/core/ffmpeg/concatenateVideosAndMergeAudio.mts +130 -0
  35. src/core/ffmpeg/concatenateVideosWithAudio.mts +158 -0
  36. src/core/ffmpeg/convertAudioToWav.mts +69 -0
  37. src/core/ffmpeg/convertMp4ToMp3.mts +65 -0
  38. src/core/ffmpeg/convertMp4ToWebm.mts +70 -0
  39. src/core/ffmpeg/createTextOverlayImage.mts +57 -0
  40. src/core/ffmpeg/createVideoFromFrames.mts +173 -0
  41. src/core/ffmpeg/cropBase64Video.mts +65 -0
  42. src/core/ffmpeg/cropVideo.mts +76 -0
  43. src/core/ffmpeg/getMediaInfo.mts +79 -0
  44. src/core/ffmpeg/scaleVideo.mts +90 -0
  45. src/core/files/deleteFileWithName.mts +17 -0
  46. src/core/files/downloadFileAsBase64.mts +27 -0
  47. src/core/files/readJpegFileToBase64.mts +18 -0
  48. src/core/files/readMp3FileToBase64.mts +18 -0
  49. src/core/files/readMp4FileToBase64.mts +18 -0
  50. src/core/files/readPlainText.mts +13 -0
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log
3
+ models
4
+ sandbox
5
+ audio.pipe
6
+ video.pipe
.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+
2
+ # the secret micro service key used in various API spaces
3
+ MICROSERVICE_API_SECRET_TOKEN=""
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ samples
2
+ node_modules
3
+ *.log
4
+ *.bin
5
+ .DS_Store
6
+ .venv
7
+ *.mp4
8
+ *.wav
9
+ *.mp3
10
+ *.webp
11
+ sandbox
12
+ scripts
13
+ .env.local
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ v20.10.0
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # And Node 20
2
+ FROM node:20-alpine
3
+
4
+ ARG DEBIAN_FRONTEND=noninteractive
5
+
6
+ RUN apk update
7
+
8
+ RUN apk add alpine-sdk pkgconfig
9
+
10
+ # For FFMPEG and gl concat
11
+ RUN apk add curl python3 python3-dev libx11-dev libsm-dev libxrender libxext-dev mesa-dev xvfb libxi-dev glew-dev
12
+
13
+ # For Puppeteer
14
+ RUN apk add build-base gcompat udev ttf-opensans chromium
15
+
16
+ RUN apk add ffmpeg
17
+
18
+ # Set up a new user named "user" with user ID 1000
19
+ RUN adduser --disabled-password --uid 1001 user
20
+
21
+ # Switch to the "user" user
22
+ USER user
23
+
24
+ # Set home to the user's home directory
25
+ ENV HOME=/home/user \
26
+ PATH=/home/user/.local/bin:$PATH
27
+
28
+ # Set the working directory to the user's home directory
29
+ WORKDIR $HOME/app
30
+
31
+ # Install app dependencies
32
+ # A wildcard is used to ensure both package.json AND package-lock.json are copied
33
+ # where available (npm@5+)
34
+ COPY --chown=user package*.json $HOME/app
35
+
36
+ # make sure the .env is copied as well
37
+ COPY --chown=user .env $HOME/app
38
+
39
+ RUN ffmpeg -version
40
+
41
+ RUN npm install
42
+
43
+ # Copy the current directory contents into the container at $HOME/app setting the owner to the user
44
+ COPY --chown=user . $HOME/app
45
+
46
+ EXPOSE 7860
47
+
48
+ # we can't use this (it time out)
49
+ # CMD [ "xvfb-run", "-s", "-ac -screen 0 1920x1080x24", "npm", "run", "start" ]
50
+ CMD [ "npm", "run", "start" ]
LICENSE.txt ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Tube Clap Exporter
3
+ emoji: 🍿🤖
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ Export a full .clap (with all its assets already in) to a video
12
+
13
+ # Installation
14
+
15
+ It is important that you make sure to use the correct version of Node (Node 20)
16
+
17
+ 1. `nvm use`
18
+ 2. `npm i`
19
+ 3. clone `.env` to `.env.local`
20
+ 4. edit `.env.local` to define the secrets / api access keys
21
+ 5. `npm run start`
22
+
23
+ # Testing the Docker image
24
+
25
+ Note: you need to install Docker, and it needs to be already running.
26
+
27
+ You will also need to build it for *your* architecture.
28
+
29
+ ```bash
30
+ docker build --platform linux/arm64 -t ai-tube-clap-exporter .
31
+ docker run -it -p 7860:7860 ai-tube-clap-exporter
32
+ ```
33
+
34
+ # Architecture
35
+
36
+ AI Channels are just Hugging Face datasets.
37
+
38
+ For now, we keep everything into one big JSON index, but don't worry we can migrate this to something more efficient, such as Redis (eg. using Upstash for convenience).
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ai-tube-clap-exporter",
3
+ "version": "1.0.0",
4
+ "description": "A service to convert a .clap (will all its assets) to a video file",
5
+ "main": "src/index.mts",
6
+ "scripts": {
7
+ "start": "tsx src/index.mts",
8
+ "dev": "tsx src/index.mts",
9
+ "docker": "npm run docker:build && npm run docker:run",
10
+ "docker:build": "docker build -t ai-tube-robot .",
11
+ "docker:run": "docker run -it -p 7860:7860 ai-tube-robot",
12
+ "alchemy:test": "tsx src/core/alchemy/test.mts"
13
+ },
14
+ "author": "Julian Bilcke <[email protected]>",
15
+ "license": "Apache License",
16
+ "dependencies": {
17
+ "@types/express": "^4.17.17",
18
+ "@types/fluent-ffmpeg": "^2.1.24",
19
+ "@types/uuid": "^9.0.2",
20
+ "dotenv": "^16.3.1",
21
+ "eventsource-parser": "^1.0.0",
22
+ "express": "^4.18.2",
23
+ "fluent-ffmpeg": "^2.1.2",
24
+ "fs-extra": "^11.1.1",
25
+ "mime-types": "^2.1.35",
26
+ "node-fetch": "^3.3.1",
27
+ "puppeteer": "^22.7.0",
28
+ "sharp": "^0.33.3",
29
+ "temp-dir": "^3.0.0",
30
+ "ts-node": "^10.9.1",
31
+ "type-fest": "^4.8.2",
32
+ "uuid": "^9.0.0",
33
+ "yaml": "^2.4.1"
34
+ },
35
+ "devDependencies": {
36
+ "@types/mime-types": "^2.1.4",
37
+ "@types/node": "^20.12.7",
38
+ "tsx": "^4.7.0"
39
+ }
40
+ }
src/core/base64/addBase64.mts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function addBase64Header(
2
+ image?: string,
3
+ format?:
4
+ | "jpeg" | "jpg" | "png" | "webp" | "heic"
5
+ | "mp3" | "wav"
6
+ | "mp4" | "webm"
7
+ | string
8
+ ) {
9
+
10
+ if (!image || typeof image !== "string" || image.length < 60) {
11
+ return ""
12
+ }
13
+
14
+ const ext = (`${format || ""}`.split(".").pop() || "").toLowerCase().trim()
15
+
16
+ let mime = ""
17
+ if (
18
+ ext === "jpeg" ||
19
+ ext === "jpg") {
20
+ mime = "image/jpeg"
21
+ } else if (
22
+ ext === "webp"
23
+ ) {
24
+ mime = "image/webp"
25
+ } else if (
26
+ ext === "png") {
27
+ mime = "image/png"
28
+ } else if (ext === "heic") {
29
+ mime = "image/heic"
30
+ } else if (ext === "mp3") {
31
+ mime = "audio/mp3"
32
+ } else if (ext === "mp4") {
33
+ mime = "video/mp4"
34
+ } else if (ext === "webm") {
35
+ mime = "video/webm"
36
+ } else if (ext === "wav") {
37
+ mime = "audio/wav"
38
+ } else {
39
+ throw new Error(`addBase64Header failed (unsupported format: ${format})`)
40
+ }
41
+
42
+ if (image.startsWith('data:')) {
43
+ if (image.startsWith(`data:${mime};base64,`)) {
44
+ return image
45
+ } else {
46
+ throw new Error(`addBase64Header failed (input string is NOT a ${mime} image)`)
47
+ }
48
+ } else {
49
+ return `data:${mime};base64,${image}`
50
+ }
51
+ }
src/core/base64/dataUriToBlob.mts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ export function dataUriToBlob(dataURI = "", defaultContentType = ""): Blob {
3
+ dataURI = dataURI.replace(/^data:/, '');
4
+
5
+ const type = dataURI.match(/(?:image|application|video|audio|text)\/[^;]+/)?.[0] || defaultContentType;
6
+ const base64 = dataURI.replace(/^[^,]+,/, '');
7
+ const arrayBuffer = new ArrayBuffer(base64.length);
8
+ const typedArray = new Uint8Array(arrayBuffer);
9
+
10
+ for (let i = 0; i < base64.length; i++) {
11
+ typedArray[i] = base64.charCodeAt(i);
12
+ }
13
+
14
+ return new Blob([arrayBuffer], { type });
15
+ }
src/core/base64/extractBase64.mts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * break a base64 string into sub-components
3
+ */
4
+ export function extractBase64(base64: string = ""): {
5
+ mimetype: string;
6
+ extension: string;
7
+ data: string;
8
+ buffer: Buffer;
9
+ blob: Blob;
10
+ } {
11
+ // console.log(`extractBase64(${base64.slice(0, 120)})`)
12
+ // Regular expression to extract the MIME type and the base64 data
13
+ const matches = base64.match(/^data:([A-Za-z-+/]+);base64,(.+)$/)
14
+
15
+ // console.log("matches:", matches)
16
+
17
+ if (!matches || matches.length !== 3) {
18
+ throw new Error("Invalid base64 string")
19
+ }
20
+
21
+ const mimetype = matches[1] || ""
22
+ const data = matches[2] || ""
23
+ const buffer = Buffer.from(data, "base64")
24
+ const blob = new Blob([buffer])
25
+
26
+ // this should be enough for most media formats (jpeg, png, webp, mp4)
27
+ const extension = mimetype.split("/").pop() || ""
28
+
29
+ return {
30
+ mimetype,
31
+ extension,
32
+ data,
33
+ buffer,
34
+ blob,
35
+ }
36
+ }
src/core/clap/getClapAssetSourceType.mts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ClapAssetSource } from "./types.mts"
2
+
3
+ export function getClapAssetSourceType(input: string = ""): ClapAssetSource {
4
+
5
+ const str = `${input || ""}`.trim()
6
+
7
+ if (!str || !str.length) {
8
+ return "EMPTY"
9
+ }
10
+
11
+ if (str.startsWith("https://") || str.startsWith("http://")) {
12
+ return "REMOTE"
13
+ }
14
+
15
+ // note that "path" assets are potentially a security risk, they need to be treated with care
16
+ if (str.startsWith("/") || str.startsWith("../") || str.startsWith("./")) {
17
+ return "PATH"
18
+ }
19
+
20
+ if (str.startsWith("data:")) {
21
+ return "DATA"
22
+ }
23
+
24
+ return "PROMPT"
25
+ }
src/core/clap/parseClap.mts ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { v4 as uuidv4 } from "uuid"
3
+ import YAML from "yaml"
4
+
5
+ import { ClapHeader, ClapMeta, ClapModel, ClapProject, ClapScene, ClapSegment } from "./types.mts"
6
+ import { getValidNumber } from "../parsers/getValidNumber.mts"
7
+ import { dataUriToBlob } from "../base64/dataUriToBlob.mts"
8
+
9
+ type StringOrBlob = string | Blob
10
+
11
+ /**
12
+ * Import a clap file from various data sources into an ClapProject
13
+ *
14
+ * Inputs can be:
15
+ * - a Clap project (which is an object)
16
+ * - an URL to a remote .clap file
17
+ * - a string containing a YAML array
18
+ * - a data uri containing a gzipped YAML array
19
+ * - a Blob containing a gzipped YAML array
20
+ *
21
+ * note: it is not really async, because for some reason YAML.parse is a blocking call like for JSON,
22
+ * there is no async version although we are now in the 20s not 90s
23
+ */
24
+ export async function parseClap(src?: ClapProject | string | Blob, debug = false): Promise<ClapProject> {
25
+
26
+ try {
27
+ if (
28
+ typeof src === "object" &&
29
+ Array.isArray( (src as any)?.scenes) &&
30
+ Array.isArray((src as any)?.models)
31
+ ) {
32
+ if (debug) {
33
+ console.log("parseClap: input is already a Clap file, nothing to do:", src)
34
+ }
35
+ // we can skip verification
36
+ return src as ClapProject
37
+ }
38
+ } catch (err) {
39
+ // well, this is not a clap project
40
+ }
41
+
42
+ let stringOrBlob = (src || "") as StringOrBlob
43
+
44
+ // both should work
45
+ const dataUriHeader1 = "data:application/x-gzip;base64,"
46
+ const dataUriHeader2 = "data:application/octet-stream;base64,"
47
+
48
+ const inputIsString = typeof stringOrBlob === "string"
49
+ const inputIsDataUri = typeof stringOrBlob === "string" ? stringOrBlob.startsWith(dataUriHeader1) || stringOrBlob.startsWith(dataUriHeader2) : false
50
+ const inputIsRemoteFile = typeof stringOrBlob === "string" ? (stringOrBlob.startsWith("http://") || stringOrBlob.startsWith("https://")) : false
51
+
52
+ let inputIsBlob = typeof stringOrBlob !== "string"
53
+
54
+ let inputYamlArrayString = ""
55
+
56
+ if (debug) {
57
+ console.log(`parseClap: pre-analysis: ${JSON.stringify({
58
+ inputIsString,
59
+ inputIsBlob,
60
+ inputIsDataUri,
61
+ inputIsRemoteFile
62
+ }, null, 2)}`)
63
+ }
64
+
65
+ if (typeof stringOrBlob === "string") {
66
+ if (debug) {
67
+ console.log("parseClap: input is a string ", stringOrBlob.slice(0, 120))
68
+ }
69
+ if (inputIsDataUri) {
70
+ if (debug) {
71
+ console.log(`parseClap: input is a data uri archive`)
72
+ }
73
+ stringOrBlob = dataUriToBlob(stringOrBlob, "application/x-gzip")
74
+ if (debug) {
75
+ console.log(`parseClap: inputBlob = `, stringOrBlob)
76
+ }
77
+ inputIsBlob = true
78
+ } else if (inputIsRemoteFile) {
79
+ try {
80
+ if (debug) {
81
+ console.log(`parseClap: input is a remote .clap file`)
82
+ }
83
+ const res = await fetch(stringOrBlob)
84
+ stringOrBlob = await res.blob()
85
+ if (!stringOrBlob) { throw new Error("blob is empty") }
86
+ inputIsBlob = true
87
+ } catch (err) {
88
+ // url seems invalid
89
+ throw new Error(`failed to download the .clap file (${err})`)
90
+ }
91
+ } else {
92
+ if (debug) {
93
+ console.log("parseClap: input is a text string containing a YAML array")
94
+ }
95
+ inputYamlArrayString = stringOrBlob
96
+ inputIsBlob = false
97
+ }
98
+ }
99
+
100
+ if (typeof stringOrBlob !== "string" && stringOrBlob) {
101
+ if (debug) {
102
+ console.log("parseClap: decompressing the blob..")
103
+ }
104
+ // Decompress the input blob using gzip
105
+ const decompressedStream = stringOrBlob.stream().pipeThrough(new DecompressionStream('gzip'))
106
+
107
+ try {
108
+ // Convert the stream to text using a Response object
109
+ const decompressedOutput = new Response(decompressedStream)
110
+ // decompressedOutput.headers.set("Content-Type", "application/x-gzip")
111
+ if (debug) {
112
+ console.log("parseClap: decompressedOutput: ", decompressedOutput)
113
+ }
114
+ // const blobAgain = await decompressedOutput.blob()
115
+ inputYamlArrayString = await decompressedOutput.text()
116
+
117
+ if (debug && inputYamlArrayString) {
118
+ console.log("parseClap: successfully decompressed the blob!")
119
+ }
120
+ } catch (err) {
121
+ const message = `parseClap: failed to decompress (${err})`
122
+ console.error(message)
123
+ throw new Error(message)
124
+ }
125
+ }
126
+
127
+ // we don't need this anymore I think
128
+ // new Blob([inputStringOrBlob], { type: "application/x-yaml" })
129
+
130
+ let maybeArray: any = {}
131
+ try {
132
+ if (debug) {
133
+ console.log("parseClap: parsing the YAML array..")
134
+ }
135
+ // Parse YAML string to raw data
136
+ maybeArray = YAML.parse(inputYamlArrayString)
137
+ } catch (err) {
138
+ throw new Error("invalid clap file (input string is not YAML)")
139
+ }
140
+
141
+ if (!Array.isArray(maybeArray) || maybeArray.length < 2) {
142
+ throw new Error("invalid clap file (need a clap format header block and project metadata block)")
143
+ }
144
+
145
+ if (debug) {
146
+ console.log("parseClap: the YAML seems okay, continuing decoding..")
147
+ }
148
+
149
+ const maybeClapHeader = maybeArray[0] as ClapHeader
150
+
151
+ if (maybeClapHeader.format !== "clap-0") {
152
+ throw new Error("invalid clap file (sorry, but you can't make up version numbers like that)")
153
+ }
154
+
155
+
156
+ const maybeClapMeta = maybeArray[1] as ClapMeta
157
+
158
+ const clapMeta: ClapMeta = {
159
+ id: typeof maybeClapMeta.title === "string" ? maybeClapMeta.id : uuidv4(),
160
+ title: typeof maybeClapMeta.title === "string" ? maybeClapMeta.title : "",
161
+ description: typeof maybeClapMeta.description === "string" ? maybeClapMeta.description : "",
162
+ synopsis: typeof maybeClapMeta.synopsis === "string" ? maybeClapMeta.synopsis : "",
163
+ licence: typeof maybeClapMeta.licence === "string" ? maybeClapMeta.licence : "",
164
+ orientation: maybeClapMeta.orientation === "portrait" ? "portrait" : maybeClapMeta.orientation === "square" ? "square" : "landscape",
165
+ durationInMs: getValidNumber(maybeClapMeta.durationInMs, 1000, Number.MAX_SAFE_INTEGER, 4000),
166
+ width: getValidNumber(maybeClapMeta.width, 128, 8192, 1024),
167
+ height: getValidNumber(maybeClapMeta.height, 128, 8192, 576),
168
+ defaultVideoModel: typeof maybeClapMeta.defaultVideoModel === "string" ? maybeClapMeta.defaultVideoModel : "SVD",
169
+ extraPositivePrompt: Array.isArray(maybeClapMeta.extraPositivePrompt) ? maybeClapMeta.extraPositivePrompt : [],
170
+ screenplay: typeof maybeClapMeta.screenplay === "string" ? maybeClapMeta.screenplay : "",
171
+ isLoop: typeof maybeClapMeta.isLoop === "boolean" ? maybeClapMeta.isLoop : false,
172
+ isInteractive: typeof maybeClapMeta.isInteractive === "boolean" ? maybeClapMeta.isInteractive : false,
173
+ }
174
+
175
+ /*
176
+ in case we want to support streaming (mix of models and segments etc), we could do it this way:
177
+
178
+ const maybeModelsOrSegments = rawData.slice(2)
179
+ maybeModelsOrSegments.forEach((unknownElement: any) => {
180
+ if (isValidNumber(unknownElement?.track)) {
181
+ maybeSegments.push(unknownElement as ClapSegment)
182
+ } else {
183
+ maybeModels.push(unknownElement as ClapModel)
184
+ }
185
+ })
186
+ */
187
+
188
+
189
+ const expectedNumberOfModels = maybeClapHeader.numberOfModels || 0
190
+ const expectedNumberOfScenes = maybeClapHeader.numberOfScenes || 0
191
+ const expectedNumberOfSegments = maybeClapHeader.numberOfSegments || 0
192
+
193
+ // note: we assume the order is strictly enforced!
194
+ // if you implement streaming (mix of models and segments) you will have to rewrite this!
195
+
196
+ const afterTheHeaders = 2
197
+ const afterTheModels = afterTheHeaders + expectedNumberOfModels
198
+
199
+ const afterTheScenes = afterTheModels + expectedNumberOfScenes
200
+
201
+ // note: if there are no expected models, maybeModels will be empty
202
+ const maybeModels = maybeArray.slice(afterTheHeaders, afterTheModels) as ClapModel[]
203
+
204
+ // note: if there are no expected scenes, maybeScenes will be empty
205
+ const maybeScenes = maybeArray.slice(afterTheModels, afterTheScenes) as ClapScene[]
206
+
207
+ const maybeSegments = maybeArray.slice(afterTheScenes) as ClapSegment[]
208
+
209
+ const clapModels: ClapModel[] = maybeModels.map(({
210
+ id,
211
+ category,
212
+ triggerName,
213
+ label,
214
+ description,
215
+ author,
216
+ thumbnailUrl,
217
+ seed,
218
+ assetSourceType,
219
+ assetUrl,
220
+ age,
221
+ gender,
222
+ region,
223
+ appearance,
224
+ voiceVendor,
225
+ voiceId,
226
+ }) => ({
227
+ // TODO: we should verify each of those, probably
228
+ id,
229
+ category,
230
+ triggerName,
231
+ label,
232
+ description,
233
+ author,
234
+ thumbnailUrl,
235
+ seed,
236
+ assetSourceType,
237
+ assetUrl,
238
+ age,
239
+ gender,
240
+ region,
241
+ appearance,
242
+ voiceVendor,
243
+ voiceId,
244
+ }))
245
+
246
+ const clapScenes: ClapScene[] = maybeScenes.map(({
247
+ id,
248
+ scene,
249
+ line,
250
+ rawLine,
251
+ sequenceFullText,
252
+ sequenceStartAtLine,
253
+ sequenceEndAtLine,
254
+ startAtLine,
255
+ endAtLine,
256
+ events,
257
+ }) => ({
258
+ id,
259
+ scene,
260
+ line,
261
+ rawLine,
262
+ sequenceFullText,
263
+ sequenceStartAtLine,
264
+ sequenceEndAtLine,
265
+ startAtLine,
266
+ endAtLine,
267
+ events: events.map(e => e)
268
+ }))
269
+
270
+ const clapSegments: ClapSegment[] = maybeSegments.map(({
271
+ id,
272
+ track,
273
+ startTimeInMs,
274
+ endTimeInMs,
275
+ category,
276
+ modelId,
277
+ sceneId,
278
+ prompt,
279
+ label,
280
+ outputType,
281
+ renderId,
282
+ status,
283
+ assetUrl,
284
+ assetDurationInMs,
285
+ createdBy,
286
+ editedBy,
287
+ outputGain,
288
+ seed,
289
+ }) => ({
290
+ // TODO: we should verify each of those, probably
291
+ id,
292
+ track,
293
+ startTimeInMs,
294
+ endTimeInMs,
295
+ category,
296
+ modelId,
297
+ sceneId,
298
+ prompt,
299
+ label,
300
+ outputType,
301
+ renderId,
302
+ status,
303
+ assetUrl,
304
+ assetDurationInMs,
305
+ createdBy,
306
+ editedBy,
307
+ outputGain,
308
+ seed,
309
+ }))
310
+
311
+ if (debug) {
312
+ console.log(`parseClap: successfully parsed ${clapModels.length} models, ${clapScenes.length} scenes and ${clapSegments.length} segments`)
313
+ }
314
+ return {
315
+ meta: clapMeta,
316
+ models: clapModels,
317
+ scenes: clapScenes,
318
+ segments: clapSegments
319
+ }
320
+ }
src/core/clap/types.mts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ export type ClapSegmentCategory =
3
+ | "splat"
4
+ | "mesh"
5
+ | "depth"
6
+ | "event"
7
+ | "interface"
8
+ | "phenomenon"
9
+ | "video"
10
+ | "storyboard"
11
+ | "transition"
12
+ | "characters"
13
+ | "location"
14
+ | "time"
15
+ | "era"
16
+ | "lighting"
17
+ | "weather"
18
+ | "action"
19
+ | "music"
20
+ | "sound"
21
+ | "dialogue"
22
+ | "style"
23
+ | "camera"
24
+ | "generic"
25
+
26
+ export type ClapOutputType =
27
+ | "text"
28
+ | "animation"
29
+ | "interface"
30
+ | "event"
31
+ | "phenomenon"
32
+ | "transition"
33
+ | "image"
34
+ | "video"
35
+ | "audio"
36
+
37
+ export type ClapSegmentStatus =
38
+ | "to_generate"
39
+ | "to_interpolate"
40
+ | "to_upscale"
41
+ | "completed"
42
+ | "error"
43
+
44
+ export type ClapAuthor =
45
+ | "auto" // the element was edited automatically using basic if/else logical rules
46
+ | "ai" // the element was edited using a large language model
47
+ | "human" // the element was edited by a human
48
+
49
+ export type ClapAssetSource =
50
+ | "REMOTE" // http:// or https://
51
+
52
+ // note that "path" assets are potentially a security risk, they need to be treated with care
53
+ | "PATH" // a file path eg. /path or ./path/to/ or ../path/to/
54
+
55
+ | "DATA" // a data URI, starting with data:
56
+
57
+ | "PROMPT" // by default, a plain text prompt
58
+
59
+ | "EMPTY"
60
+
61
+ export type ClapModelGender =
62
+ | "male"
63
+ | "female"
64
+ | "person"
65
+ | "object"
66
+
67
+ export type ClapModelAppearance = "serious" | "neutral" | "friendly" | "chill"
68
+
69
+ // this is used for accent, style..
70
+ export type ClapModelRegion =
71
+ | "american"
72
+ | "british"
73
+ | "australian"
74
+ | "canadian"
75
+ | "indian"
76
+ | "french"
77
+ | "italian"
78
+ | "german"
79
+ | "chinese"
80
+
81
+ // note: this is all very subjective, so please use good judgment
82
+ //
83
+ // "deep" might indicate a deeper voice tone, thicker, rich in harmonics
84
+ // in this context, it is used to indicate voices that could
85
+ // be associated with African American (AADOS) characters
86
+ //
87
+ // "high" could be used for some other countries, eg. asia
88
+ export type ClapModelTimbre = "high" | "neutral" | "deep"
89
+
90
+ export type ClapVoiceVendor = "ElevenLabs" | "XTTS"
91
+
92
+ export type ClapVoice = {
93
+ name: string
94
+ gender: ClapModelGender
95
+ age: number
96
+ region: ClapModelRegion
97
+ timbre: ClapModelTimbre
98
+ appearance: ClapModelAppearance
99
+ voiceVendor: ClapVoiceVendor
100
+ voiceId: string
101
+ }
102
+
103
+ export type ClapHeader = {
104
+ format: "clap-0"
105
+ numberOfModels: number
106
+ numberOfScenes: number
107
+ numberOfSegments: number
108
+ }
109
+
110
+ export type ClapMeta = {
111
+ id: string
112
+ title: string
113
+ description: string
114
+ synopsis: string
115
+ licence: string
116
+ orientation: string
117
+
118
+ // the default duration of the experience
119
+ // the real one might last longer if made interactive
120
+ durationInMs: number
121
+
122
+ width: number
123
+ height: number
124
+ defaultVideoModel: string
125
+ extraPositivePrompt: string[]
126
+ screenplay: string
127
+ isLoop: boolean
128
+ isInteractive: boolean
129
+ }
130
+
131
+ export type ClapSceneEvent = {
132
+ id: string
133
+ type: "description" | "dialogue" | "action"
134
+ character?: string
135
+ description: string
136
+ behavior: string
137
+ startAtLine: number
138
+ endAtLine: number
139
+ }
140
+
141
+ export type ClapScene = {
142
+ id: string
143
+ scene: string
144
+ line: string
145
+ rawLine: string
146
+ sequenceFullText: string
147
+ sequenceStartAtLine: number
148
+ sequenceEndAtLine: number
149
+ startAtLine: number
150
+ endAtLine: number
151
+ events: ClapSceneEvent[]
152
+ }
153
+
154
+ export type ClapSegment = {
155
+ id: string
156
+ track: number
157
+ startTimeInMs: number
158
+ endTimeInMs: number
159
+ category: ClapSegmentCategory
160
+ modelId: string
161
+ sceneId: string
162
+ prompt: string
163
+ label: string
164
+ outputType: ClapOutputType
165
+ renderId: string
166
+ status: ClapSegmentStatus
167
+ assetUrl: string
168
+ assetDurationInMs: number
169
+ createdBy: ClapAuthor
170
+ editedBy: ClapAuthor
171
+ outputGain: number
172
+ seed: number
173
+ }
174
+
175
+ export type ClapModel = {
176
+ id: string
177
+ category: ClapSegmentCategory
178
+ triggerName: string
179
+ label: string
180
+ description: string
181
+ author: string
182
+ thumbnailUrl: string
183
+ seed: number
184
+
185
+ assetSourceType: ClapAssetSource
186
+ assetUrl: string
187
+
188
+ // those are only used by certain types of models
189
+ age: number
190
+ gender: ClapModelGender
191
+ region: ClapModelRegion
192
+ appearance: ClapModelAppearance
193
+ voiceVendor: ClapVoiceVendor
194
+ voiceId: string
195
+ }
196
+
197
+ export type ClapProject = {
198
+ meta: ClapMeta
199
+ models: ClapModel[]
200
+ scenes: ClapScene[]
201
+ segments: ClapSegment[]
202
+ // let's keep room for other stuff (screenplay etc)
203
+ }
src/core/converters/blobToWebp.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function blobToWebp(blob: Blob) {
4
+ return addBase64Header(Buffer.from(await blob.text()).toString('base64'), "webp")
5
+ }
src/core/converters/bufferToJpeg.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToJpeg(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "jpeg")
5
+ }
src/core/converters/bufferToMp3.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToMp3(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "mp3")
5
+ }
src/core/converters/bufferToMp4.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToMp4(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "mp4")
5
+ }
src/core/converters/bufferToPng.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToPng(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "png")
5
+ }
src/core/converters/bufferToWav.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToWav(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "wav")
5
+ }
src/core/converters/bufferToWebp.mts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { addBase64Header } from "../base64/addBase64.mts";
2
+
3
+ export async function bufferToWebp(buffer: Buffer) {
4
+ return addBase64Header(buffer.toString('base64'), "webp")
5
+ }
src/core/converters/convertImageTo.mts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { convertImageToJpeg } from "./convertImageToJpeg.mts"
2
+ import { convertImageToPng } from "./convertImageToPng.mts"
3
+ import { convertImageToWebp } from "./convertImageToWebp.mts"
4
+ import { ImageFileExt } from "./imageFormats.mts"
5
+
6
+ /**
7
+ * Convert an image to one of the supported file formats
8
+ *
9
+ * @param imgBase64
10
+ * @param outputFormat
11
+ * @returns
12
+ */
13
+ export async function convertImageTo(imgBase64: string = "", outputFormat: ImageFileExt): Promise<string> {
14
+ const format = outputFormat.trim().toLowerCase() as ImageFileExt
15
+ if (!["jpeg", "jpg", "png", "webp"].includes(format)) {
16
+ throw new Error(`unsupported file format "${format}"`)
17
+ }
18
+
19
+ const isJpeg = format === "jpg" || format === "jpeg"
20
+
21
+
22
+ if (isJpeg) {
23
+ return convertImageToJpeg(imgBase64)
24
+ }
25
+
26
+ if (format === "webp") {
27
+ return convertImageToWebp(imgBase64)
28
+ }
29
+
30
+ return convertImageToPng(imgBase64)
31
+ }
src/core/converters/convertImageToJpeg.mts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sharp from "sharp"
2
+
3
+ export async function convertImageToJpeg(imgBase64: string = "", quality: number = 92): Promise<string> {
4
+
5
+ const base64WithoutHeader = imgBase64.split(";base64,")[1] || ""
6
+
7
+ if (!base64WithoutHeader) {
8
+ const slice = `${imgBase64 || ""}`.slice(0, 50)
9
+ throw new Error(`couldn't process input image "${slice}..."`)
10
+ }
11
+
12
+ // Convert base64 to buffer
13
+ const tmpBuffer = Buffer.from(base64WithoutHeader, 'base64')
14
+
15
+ // Resize the buffer to the target size
16
+ const newBuffer = await sharp(tmpBuffer)
17
+ .jpeg({
18
+ quality,
19
+ // we don't use progressive: true because we pre-load images anyway
20
+ })
21
+ .toBuffer()
22
+
23
+ // Convert the buffer back to base64
24
+ const newImageBase64 = newBuffer.toString('base64')
25
+
26
+ return `data:image/jpeg;base64,${newImageBase64}`
27
+ }
src/core/converters/convertImageToOriginal.mts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+
2
+ // you are reading it right: this function does.. nothing!
3
+ // it is a NOOP conversion function
4
+ export async function convertImageToOriginal(imgBase64: string = ""): Promise<string> {
5
+ return imgBase64
6
+ }
src/core/converters/convertImageToPng.mts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sharp from "sharp"
2
+
3
+ export async function convertImageToPng(imgBase64: string = ""): Promise<string> {
4
+
5
+ const base64WithoutHeader = imgBase64.split(";base64,")[1] || ""
6
+
7
+ if (!base64WithoutHeader) {
8
+ const slice = `${imgBase64 || ""}`.slice(0, 50)
9
+ throw new Error(`couldn't process input image "${slice}..."`)
10
+ }
11
+
12
+ // Convert base64 to buffer
13
+ const tmpBuffer = Buffer.from(base64WithoutHeader, 'base64')
14
+
15
+ const newBuffer = await sharp(tmpBuffer)
16
+ .png()
17
+ .toBuffer()
18
+
19
+ // Convert the buffer back to base64
20
+ const newImageBase64 = newBuffer.toString('base64')
21
+
22
+ return `data:image/png;base64,${newImageBase64}`
23
+ }
src/core/converters/convertImageToWebp.mts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sharp from "sharp"
2
+
3
+ export async function convertImageToWebp(imgBase64: string = ""): Promise<string> {
4
+
5
+ const base64WithoutHeader = imgBase64.split(";base64,")[1] || ""
6
+
7
+ if (!base64WithoutHeader) {
8
+ const slice = `${imgBase64 || ""}`.slice(0, 50)
9
+ throw new Error(`couldn't process input image "${slice}..."`)
10
+ }
11
+
12
+ // Convert base64 to buffer
13
+ const tmpBuffer = Buffer.from(base64WithoutHeader, 'base64')
14
+
15
+ // Resize the buffer to the target size
16
+ const newBuffer = await sharp(tmpBuffer)
17
+ .webp({
18
+ // for options please see https://sharp.pixelplumbing.com/api-output#webp
19
+
20
+ // preset: "photo",
21
+
22
+ // effort: 3,
23
+
24
+ // for a PNG-like quality
25
+ // lossless: true,
26
+
27
+ // by default it is quality 80
28
+ quality: 80,
29
+
30
+ // nearLossless: true,
31
+
32
+ // use high quality chroma subsampling
33
+ smartSubsample: true,
34
+ })
35
+ .toBuffer()
36
+
37
+ // Convert the buffer back to base64
38
+ const newImageBase64 = newBuffer.toString('base64')
39
+
40
+ return `data:image/webp;base64,${newImageBase64}`
41
+ }
src/core/converters/htmlToBase64Png.mts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { v4 as uuidv4 } from "uuid"
6
+ import puppeteer from "puppeteer"
7
+
8
+ export async function htmlToBase64Png({
9
+ outputImagePath,
10
+ html,
11
+ width = 800,
12
+ height = 600,
13
+ }: {
14
+ outputImagePath?: string
15
+ html?: string
16
+ width?: number
17
+ height: number
18
+ }): Promise<{
19
+ filePath: string
20
+ buffer: Buffer
21
+ }> {
22
+
23
+ // If no output path is provided, create a temporary file for output
24
+ if (!outputImagePath) {
25
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), uuidv4()))
26
+
27
+ outputImagePath = path.join(tempDir, `${uuidv4()}.png`)
28
+ }
29
+
30
+ const browser = await puppeteer.launch({
31
+ headless: "new",
32
+
33
+ // apparently we need those, see:
34
+ // https://unix.stackexchange.com/questions/694734/puppeteer-in-alpine-docker-with-chromium-headless-dosent-seems-to-work
35
+ executablePath: '/usr/bin/chromium-browser',
36
+ args: [
37
+ '--no-sandbox',
38
+ '--headless',
39
+ '--disable-gpu',
40
+ '--disable-dev-shm-usage'
41
+ ]
42
+ })
43
+
44
+ const page = await browser.newPage()
45
+
46
+ page.setViewport({
47
+ width,
48
+ height,
49
+ })
50
+
51
+ try {
52
+ await page.setContent(html)
53
+
54
+ const content = await page.$("body")
55
+
56
+ const buffer = await content.screenshot({
57
+ path: outputImagePath,
58
+ omitBackground: true,
59
+ captureBeyondViewport: false,
60
+
61
+ // we must keep PNG here, if we want transparent backgrounds
62
+ type: "png",
63
+
64
+ // we should leave it to binary (the default value) if we save to a file
65
+ // encoding: "binary", // "base64",
66
+ })
67
+
68
+ return {
69
+ filePath: outputImagePath,
70
+ buffer
71
+ }
72
+ } catch (err) {
73
+ throw err
74
+ } finally {
75
+ await page.close()
76
+ await browser.close()
77
+ }
78
+ };
src/core/converters/imageFormats.mts ADDED
@@ -0,0 +1 @@
 
 
1
+ export type ImageFileExt = "png" | "jpeg" | "jpg" | "webp"
src/core/ffmpeg/addImageToVideo.mts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs, existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import ffmpeg from "fluent-ffmpeg";
5
+ import { v4 as uuidv4 } from "uuid";
6
+
7
+ type AddImageToVideoParams = {
8
+ inputVideoPath: string;
9
+ inputImagePath: string;
10
+ outputVideoPath?: string;
11
+ };
12
+
13
+ export async function addImageToVideo({
14
+ inputVideoPath,
15
+ inputImagePath,
16
+ outputVideoPath,
17
+ }: AddImageToVideoParams): Promise<string> {
18
+ // Verify that the input files exist
19
+ if (!existsSync(inputVideoPath)) {
20
+ throw new Error(`Input video file does not exist: ${inputVideoPath}`);
21
+ }
22
+ if (!existsSync(inputImagePath)) {
23
+ throw new Error(`Input image file does not exist: ${inputImagePath}`);
24
+ }
25
+
26
+ // If no output path is provided, create a temporary file for output
27
+ if (!outputVideoPath) {
28
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), uuidv4()));
29
+ outputVideoPath = path.join(tempDir, `${uuidv4()}.mp4`);
30
+ }
31
+
32
+ // Return a promise that resolves with the path to the output video
33
+ return new Promise((resolve, reject) => {
34
+ ffmpeg(inputVideoPath)
35
+ .input(inputImagePath)
36
+ .complexFilter([
37
+ {
38
+ filter: "overlay",
39
+ options: { x: "0", y: "0" }, // Overlay on the entire video frame
40
+ }
41
+ ])
42
+ .on("error", (err) => {
43
+ reject(new Error(`Error processing video: ${err.message}`));
44
+ })
45
+ .on("end", () => {
46
+ resolve(outputVideoPath);
47
+ })
48
+ .save(outputVideoPath);
49
+ });
50
+ }
src/core/ffmpeg/addTextToVideo.mts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTextOverlayImage } from "./createTextOverlayImage.mts";
2
+ import { addImageToVideo } from "./addImageToVideo.mts";
3
+
4
+ export async function addTextToVideo() {
5
+
6
+ const inputVideoPath = "/Users/jbilcke/Downloads/use_me.mp4"
7
+
8
+ const { filePath } = await createTextOverlayImage({
9
+ text: "This tech is hot 🥵",
10
+ width: 1024 ,
11
+ height: 576,
12
+ })
13
+ console.log("filePath:", filePath)
14
+
15
+ /*
16
+ const pathToVideo = await addImageToVideo({
17
+ inputVideoPath,
18
+ inputImagePath: filePath,
19
+ })
20
+
21
+ console.log("pathToVideo:", pathToVideo)
22
+ */
23
+ }
src/core/ffmpeg/concatenateAudio.mts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg";
7
+ import { writeBase64ToFile } from "../files/writeBase64ToFile.mts";
8
+ import { getMediaInfo } from "./getMediaInfo.mts";
9
+ import { removeTemporaryFiles } from "../files/removeTmpFiles.mts";
10
+ import { addBase64Header } from "../base64/addBase64.mts";
11
+
12
+ export type ConcatenateAudioOptions = {
13
+ // those are base64 audio strings!
14
+ audioTracks?: string[]; // base64
15
+ audioFilePaths?: string[]; // path
16
+ crossfadeDurationInSec?: number;
17
+ outputFormat?: string; // "wav" or "mp3"
18
+ output?: string;
19
+ }
20
+
21
+ export type ConcatenateAudioOutput = {
22
+ filepath: string;
23
+ durationInSec: number;
24
+ }
25
+
26
+ export async function concatenateAudio({
27
+ output,
28
+ audioTracks = [],
29
+ audioFilePaths = [],
30
+ crossfadeDurationInSec = 10,
31
+ outputFormat = "wav"
32
+ }: ConcatenateAudioOptions): Promise<ConcatenateAudioOutput> {
33
+ if (!Array.isArray(audioTracks)) {
34
+ throw new Error("Audios must be provided in an array");
35
+ }
36
+
37
+ const tempDir = path.join(os.tmpdir(), uuidv4());
38
+ await fs.mkdir(tempDir);
39
+
40
+ // console.log(" |- created tmp dir")
41
+
42
+ // trivial case: there is only one audio to concatenate!
43
+ if (audioTracks.length === 1 && audioTracks[0]) {
44
+ const audioTrack = audioTracks[0]
45
+ const outputFilePath = path.join(tempDir, `audio_0.${outputFormat}`);
46
+ await writeBase64ToFile(addBase64Header(audioTrack, "wav"), outputFilePath);
47
+
48
+ // console.log(" |- there is only one track! so.. returning that")
49
+ const { durationInSec } = await getMediaInfo(outputFilePath);
50
+ return { filepath: outputFilePath, durationInSec };
51
+ }
52
+
53
+ if (audioFilePaths.length === 1) {
54
+ throw new Error("concatenating a single audio file path is not implemented yet")
55
+ }
56
+
57
+ try {
58
+
59
+ let i = 0
60
+ for (const track of audioTracks) {
61
+ if (!track) { continue }
62
+ const audioFilePath = path.join(tempDir, `audio_${++i}.wav`);
63
+ await writeBase64ToFile(addBase64Header(track, "wav"), audioFilePath);
64
+ audioFilePaths.push(audioFilePath);
65
+ }
66
+
67
+ audioFilePaths = audioFilePaths.filter((audio) => existsSync(audio))
68
+
69
+ const outputFilePath = output ?? path.join(tempDir, `${uuidv4()}.${outputFormat}`);
70
+
71
+ let filterComplex = "";
72
+ let prevLabel = "0";
73
+
74
+ for (let i = 0; i < audioFilePaths.length - 1; i++) {
75
+ const nextLabel = `a${i}`;
76
+ filterComplex += `[${prevLabel}][${i + 1}]acrossfade=d=${crossfadeDurationInSec}:c1=tri:c2=tri[${nextLabel}];`;
77
+ prevLabel = nextLabel;
78
+ }
79
+
80
+
81
+ console.log(" |- concatenateAudio(): DEBUG:", {
82
+ tempDir,
83
+ audioFilePaths,
84
+ outputFilePath,
85
+ filterComplex,
86
+ prevLabel
87
+ })
88
+
89
+ let cmd: FfmpegCommand = ffmpeg() // .outputOptions('-vn');
90
+
91
+ audioFilePaths.forEach((audio, i) => {
92
+ cmd = cmd.input(audio);
93
+ });
94
+
95
+
96
+ const promise = new Promise<ConcatenateAudioOutput>((resolve, reject) => {
97
+ cmd = cmd
98
+ .on('error', reject)
99
+ .on('end', async () => {
100
+ try {
101
+ const { durationInSec } = await getMediaInfo(outputFilePath);
102
+ // console.log("concatenation ended! see ->", outputFilePath)
103
+ resolve({ filepath: outputFilePath, durationInSec });
104
+ } catch (err) {
105
+ reject(err);
106
+ }
107
+ })
108
+ .complexFilter(filterComplex, prevLabel)
109
+ .save(outputFilePath);
110
+ });
111
+
112
+ const result = await promise
113
+
114
+ return result
115
+ } catch (error) {
116
+ console.error(`Failed to assemble audio!`)
117
+ console.error(error)
118
+ throw new Error(`Failed to assemble audio: ${(error as Error)?.message || error}`);
119
+ } finally {
120
+ await removeTemporaryFiles(audioFilePaths)
121
+ }
122
+ }
src/core/ffmpeg/concatenateVideos.mts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg";
7
+
8
+ import { getMediaInfo } from "./getMediaInfo.mts";
9
+
10
+ export type ConcatenateVideoOutput = {
11
+ filepath: string;
12
+ durationInSec: number;
13
+ }
14
+
15
+ export async function concatenateVideos({
16
+ output,
17
+ videoFilePaths = [],
18
+ }: {
19
+ output?: string;
20
+
21
+ // those are videos PATHs, not base64 strings!
22
+ videoFilePaths: string[];
23
+ }): Promise<ConcatenateVideoOutput> {
24
+ if (!Array.isArray(videoFilePaths)) {
25
+ throw new Error("Videos must be provided in an array");
26
+ }
27
+
28
+ videoFilePaths = videoFilePaths.filter((videoPath) => existsSync(videoPath))
29
+
30
+ // Create a temporary working directory
31
+ const tempDir = path.join(os.tmpdir(), uuidv4());
32
+ await fs.mkdir(tempDir);
33
+
34
+ const filePath = output ? output : path.join(tempDir, `${uuidv4()}.mp4`);
35
+
36
+ if (!filePath) {
37
+ throw new Error("Failed to generate a valid temporary file path");
38
+ }
39
+
40
+ let cmd: FfmpegCommand = ffmpeg();
41
+
42
+ videoFilePaths.forEach((video) => {
43
+ cmd = cmd.addInput(video)
44
+ })
45
+
46
+ return new Promise<{ filepath: string; durationInSec: number }>(
47
+ (resolve, reject) => {
48
+ cmd
49
+ .on('error', reject)
50
+ .on('end', async () => {
51
+ try {
52
+ const { durationInSec } = await getMediaInfo(filePath);
53
+ resolve({ filepath: filePath, durationInSec });
54
+ } catch (err) {
55
+ reject(err);
56
+ }
57
+ })
58
+ .mergeToFile(filePath, tempDir);
59
+ }
60
+ );
61
+ };
src/core/ffmpeg/concatenateVideosAndMergeAudio.mts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg";
7
+ import { concatenateVideos } from "./concatenateVideos.mts";
8
+ import { writeBase64ToFile } from "../files/writeBase64ToFile.mts";
9
+ import { getMediaInfo } from "./getMediaInfo.mts";
10
+ import { removeTemporaryFiles } from "../files/removeTmpFiles.mts";
11
+ import { addBase64Header } from "../base64/addBase64.mts";
12
+
13
+ type ConcatenateVideoAndMergeAudioOptions = {
14
+ output?: string;
15
+ audioTracks?: string[]; // base64
16
+ audioFilePaths?: string[]; // path
17
+ videoTracks?: string[]; // base64
18
+ videoFilePaths?: string[]; // path
19
+ };
20
+
21
+ export type ConcatenateVideoAndMergeAudioOutput = {
22
+ filepath: string;
23
+ durationInSec: number;
24
+ }
25
+
26
+ // note: the audio tracks will be fused together, as in "mixed"
27
+ // this return a path to the file
28
+ export const concatenateVideosAndMergeAudio = async ({
29
+ output,
30
+ audioTracks = [],
31
+ audioFilePaths = [],
32
+ videoTracks = [],
33
+ videoFilePaths = []
34
+ }: ConcatenateVideoAndMergeAudioOptions): Promise<ConcatenateVideoAndMergeAudioOutput> => {
35
+
36
+ try {
37
+ // Prepare temporary directories
38
+ const tempDir = path.join(os.tmpdir(), uuidv4());
39
+ await fs.mkdir(tempDir);
40
+
41
+ let i = 0
42
+ for (const track of audioTracks) {
43
+ if (!track) { continue }
44
+ const audioFilePath = path.join(tempDir, `audio${++i}.wav`);
45
+ await writeBase64ToFile(addBase64Header(track, "wav"), audioFilePath);
46
+ audioFilePaths.push(audioFilePath);
47
+ }
48
+ audioFilePaths = audioFilePaths.filter((audio) => existsSync(audio))
49
+
50
+
51
+ // Decode and concatenate base64 video tracks to temporary file
52
+ i = 0
53
+ for (const track of videoTracks) {
54
+ if (!track) { continue }
55
+ const videoFilePath = path.join(tempDir, `video${++i}.mp4`);
56
+
57
+ await writeBase64ToFile(addBase64Header(track, "mp4"), videoFilePath);
58
+
59
+ videoFilePaths.push(videoFilePath);
60
+ }
61
+ videoFilePaths = videoFilePaths.filter((video) => existsSync(video))
62
+
63
+ // The final output file path
64
+ const finalOutputFilePath = output ? output : path.join(tempDir, `${uuidv4()}.mp4`);
65
+
66
+ /*
67
+ console.log("DEBUG:", {
68
+ tempDir,
69
+ audioFilePath,
70
+ audioTrack: audioTrack.slice(0, 40),
71
+ videoTracks: videoTracks.map(vid => vid.slice(0, 40)),
72
+ videoFilePaths,
73
+ finalOutputFilePath
74
+ })
75
+ */
76
+
77
+ // console.log("concatenating videos (without audio)..")
78
+ const tempFilePath = await concatenateVideos({
79
+ videoFilePaths,
80
+ })
81
+ // console.log("concatenated silent shots to: ", tempFilePath)
82
+
83
+ // console.log("concatenating video + audio..")
84
+
85
+ // Add audio to the concatenated video file
86
+ const promise = new Promise<ConcatenateVideoAndMergeAudioOutput>((resolve, reject) => {
87
+ let cmd = ffmpeg().addInput(tempFilePath.filepath).outputOptions("-c:v copy");
88
+
89
+ for (const audioFilePath of audioFilePaths) {
90
+ cmd = cmd.addInput(audioFilePath);
91
+ }
92
+
93
+ if (audioFilePaths.length) {
94
+ // Mix all audio tracks (if there are any) into a single stereo stream
95
+ const mixFilter = audioFilePaths.map((_, index) => `[${index + 1}:a]`).join('') + `amix=inputs=${audioFilePaths.length}:duration=first[outa]`;
96
+ cmd = cmd
97
+ .complexFilter(mixFilter)
98
+ .outputOptions([
99
+ "-map", "0:v:0", // Maps the video stream from the first input (index 0) as the output video stream
100
+ "-map", "[outa]", // Maps the labeled audio output from the complex filter (mixed audio) as the output audio stream
101
+ "-c:a aac", // Specifies the audio codec to be AAC (Advanced Audio Coding)
102
+ "-shortest" // Ensures the output file's duration equals the shortest input stream's duration
103
+ ]);
104
+ } else {
105
+ // If there are no audio tracks, just map the video
106
+ cmd = cmd.outputOptions(["-map", "0:v:0"]);
107
+ }
108
+
109
+ cmd = cmd
110
+ .on("error", reject)
111
+ .on('end', async () => {
112
+ try {
113
+ const { durationInSec } = await getMediaInfo(finalOutputFilePath);
114
+ resolve({ filepath: finalOutputFilePath, durationInSec });
115
+ } catch (err) {
116
+ reject(err);
117
+ }
118
+ })
119
+ .saveToFile(finalOutputFilePath);
120
+ });
121
+
122
+ const result = await promise;
123
+
124
+ return result
125
+ } catch (error) {
126
+ throw new Error(`Failed to assemble video: ${(error as Error).message}`);
127
+ } finally {
128
+ await removeTemporaryFiles([...videoFilePaths, ...audioFilePaths])
129
+ }
130
+ };
src/core/ffmpeg/concatenateVideosWithAudio.mts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, promises as fs } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg";
7
+ import { concatenateVideos } from "./concatenateVideos.mts";
8
+ import { writeBase64ToFile } from "../files/writeBase64ToFile.mts";
9
+ import { getMediaInfo } from "./getMediaInfo.mts";
10
+ import { removeTemporaryFiles } from "../files/removeTmpFiles.mts";
11
+ import { addBase64Header } from "../base64/addBase64.mts";
12
+
13
+ type ConcatenateVideoWithAudioOptions = {
14
+ output?: string;
15
+ audioTrack?: string; // base64
16
+ audioFilePath?: string; // path
17
+ videoTracks?: string[]; // base64
18
+ videoFilePaths?: string[]; // path
19
+ videoTracksVolume?: number; // Represents the volume level of the original video track
20
+ audioTrackVolume?: number; // Represents the volume level of the additional audio track
21
+ asBase64?: boolean;
22
+ };
23
+
24
+
25
+ export const concatenateVideosWithAudio = async ({
26
+ output,
27
+ audioTrack = "",
28
+ audioFilePath = "",
29
+ videoTracks = [],
30
+ videoFilePaths = [],
31
+ videoTracksVolume = 0.5, // (1.0 = 100% volume)
32
+ audioTrackVolume = 0.5,
33
+ asBase64 = false,
34
+ }: ConcatenateVideoWithAudioOptions): Promise<string> => {
35
+
36
+ try {
37
+ // Prepare temporary directories
38
+ const tempDir = path.join(os.tmpdir(), uuidv4());
39
+ await fs.mkdir(tempDir);
40
+
41
+ if (audioTrack) {
42
+ audioFilePath = path.join(tempDir, `audio.wav`);
43
+ await writeBase64ToFile(addBase64Header(audioTrack, "wav"), audioFilePath);
44
+ }
45
+
46
+ // Decode and concatenate base64 video tracks to temporary file
47
+ let i = 0
48
+ for (const track of videoTracks) {
49
+ if (!track) { continue }
50
+ const videoFilePath = path.join(tempDir, `video${++i}.mp4`);
51
+
52
+ await writeBase64ToFile(addBase64Header(track, "mp4"), videoFilePath);
53
+
54
+ videoFilePaths.push(videoFilePath);
55
+ }
56
+
57
+ videoFilePaths = videoFilePaths.filter((video) => existsSync(video))
58
+
59
+ // console.log("concatenating videos (without audio)..")
60
+ const tempFilePath = await concatenateVideos({
61
+ videoFilePaths,
62
+ })
63
+
64
+ // Check if the concatenated video has audio or not
65
+ const tempMediaInfo = await getMediaInfo(tempFilePath.filepath);
66
+ const hasOriginalAudio = tempMediaInfo.hasAudio;
67
+
68
+ const finalOutputFilePath = output || path.join(tempDir, `${uuidv4()}.mp4`);
69
+
70
+ // Begin ffmpeg command configuration
71
+ let cmd = ffmpeg();
72
+
73
+ // Add silent concatenated video
74
+ cmd = cmd.addInput(tempFilePath.filepath);
75
+
76
+ // If additional audio is provided, add audio to ffmpeg command
77
+ if (audioFilePath) {
78
+ cmd = cmd.addInput(audioFilePath);
79
+ // If the input video already has audio, we will mix it with additional audio
80
+ if (hasOriginalAudio) {
81
+ const filterComplex = `
82
+ [0:a]volume=${videoTracksVolume}[a0];
83
+ [1:a]volume=${audioTrackVolume}[a1];
84
+ [a0][a1]amix=inputs=2:duration=shortest[a]
85
+ `.trim();
86
+
87
+ cmd = cmd.outputOptions([
88
+ '-filter_complex', filterComplex,
89
+ '-map', '0:v',
90
+ '-map', '[a]',
91
+ '-c:v', 'copy',
92
+ '-c:a', 'aac',
93
+ ]);
94
+ } else {
95
+ // If the input video has no audio, just use the additional audio as is
96
+ cmd = cmd.outputOptions([
97
+ '-map', '0:v',
98
+ '-map', '1:a',
99
+ '-c:v', 'copy',
100
+ '-c:a', 'aac',
101
+ ]);
102
+ }
103
+ } else {
104
+ // If no additional audio is provided, simply copy the video stream
105
+ cmd = cmd.outputOptions([
106
+ '-c:v', 'copy',
107
+ hasOriginalAudio ? '-c:a' : '-an', // If original audio exists, copy it; otherwise, indicate no audio
108
+ ]);
109
+ }
110
+
111
+ /*
112
+ console.log("DEBUG:", {
113
+ videoTracksVolume,
114
+ audioTrackVolume,
115
+ videoFilePaths,
116
+ tempFilePath,
117
+ hasOriginalAudio,
118
+ // originalAudioVolume,
119
+ audioFilePath,
120
+ // additionalAudioVolume,
121
+ finalOutputFilePath
122
+ })
123
+ */
124
+
125
+ // Set up event handlers for ffmpeg processing
126
+ const promise = new Promise<string>((resolve, reject) => {
127
+ cmd.on('error', (err) => {
128
+ console.error(" Error during ffmpeg processing:", err.message);
129
+ reject(err);
130
+ }).on('end', async () => {
131
+ // When ffmpeg finishes processing, resolve the promise with file info
132
+ try {
133
+ if (asBase64) {
134
+ try {
135
+ const outputBuffer = await fs.readFile(finalOutputFilePath);
136
+ const outputBase64 = addBase64Header(outputBuffer.toString("base64"), "mp4")
137
+ resolve(outputBase64);
138
+ } catch (error) {
139
+ reject(new Error(`Error reading output video file: ${(error as Error).message}`));
140
+ }
141
+ } else {
142
+ resolve(finalOutputFilePath)
143
+ }
144
+ } catch (err) {
145
+ reject(err);
146
+ }
147
+ }).save(finalOutputFilePath); // Provide the path where to save the file
148
+ });
149
+
150
+ // Wait for ffmpeg to complete the process
151
+ const result = await promise;
152
+ return result;
153
+ } catch (error) {
154
+ throw new Error(`Failed to assemble video: ${(error as Error).message}`);
155
+ } finally {
156
+ await removeTemporaryFiles([...videoFilePaths].concat(audioFilePath))
157
+ }
158
+ };
src/core/ffmpeg/convertAudioToWav.mts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import ffmpeg from "fluent-ffmpeg";
5
+ import { Buffer } from "node:buffer";
6
+
7
+ type ConvertAudioToWavParams = {
8
+ input: string;
9
+ outputAudioPath?: string;
10
+ asBase64?: boolean;
11
+ };
12
+
13
+ export async function convertAudioToWav({
14
+ input,
15
+ outputAudioPath,
16
+ asBase64 = false,
17
+ }: ConvertAudioToWavParams): Promise<string> {
18
+ let inputAudioPath = input;
19
+
20
+ // Check if the input is a base64 string
21
+ if (input.startsWith("data:")) {
22
+ const matches = input.match(/^data:audio\/(mp3|wav);base64,(.+)$/);
23
+
24
+ if (!matches) {
25
+ throw new Error("Invalid base64 audio data");
26
+ }
27
+
28
+ const inputBuffer = Buffer.from(matches[2], "base64");
29
+ const inputFormat = matches[1]; // Either 'mp3' or 'wav'
30
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-input-"));
31
+ inputAudioPath = path.join(tempDir, `temp.${inputFormat}`);
32
+
33
+ // Write the base64 data to the temporary file
34
+ await fs.writeFile(inputAudioPath, inputBuffer);
35
+ } else {
36
+ // Verify that the input file exists
37
+ if (!(await fs.stat(inputAudioPath)).isFile()) {
38
+ throw new Error(`Input audio file does not exist: ${inputAudioPath}`);
39
+ }
40
+ }
41
+
42
+ // If no output path is provided, create a temporary file for the output
43
+ if (!outputAudioPath) {
44
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-output-"));
45
+ outputAudioPath = path.join(tempDir, `${path.parse(inputAudioPath).name}.wav`);
46
+ }
47
+
48
+ return new Promise((resolve, reject) => {
49
+ ffmpeg(inputAudioPath)
50
+ .toFormat("wav")
51
+ .on("error", (err) => {
52
+ reject(new Error(`Error converting audio to WAV: ${err.message}`));
53
+ })
54
+ .on("end", async () => {
55
+ if (asBase64) {
56
+ try {
57
+ const audioBuffer = await fs.readFile(outputAudioPath);
58
+ const audioBase64 = `data:audio/wav;base64,${audioBuffer.toString("base64")}`;
59
+ resolve(audioBase64);
60
+ } catch (error) {
61
+ reject(new Error(`Error reading audio file: ${(error as Error).message}`));
62
+ }
63
+ } else {
64
+ resolve(outputAudioPath);
65
+ }
66
+ })
67
+ .save(outputAudioPath);
68
+ });
69
+ }
src/core/ffmpeg/convertMp4ToMp3.mts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { mkdtemp, stat, writeFile, readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import ffmpeg from "fluent-ffmpeg";
6
+ import { tmpdir } from "node:os";
7
+ import { Buffer } from "node:buffer";
8
+
9
+ export async function convertMp4ToMp3({
10
+ input,
11
+ outputAudioPath,
12
+ asBase64 = false,
13
+ }: {
14
+ input: string;
15
+ outputAudioPath?: string;
16
+ asBase64?: boolean;
17
+ }): Promise<string> {
18
+ let inputFilePath = input;
19
+
20
+ // Check if the input is a base64 string
21
+ if (input.startsWith("data:")) {
22
+ const base64Data = input.split(",")[1];
23
+ const inputBuffer = Buffer.from(base64Data, "base64");
24
+
25
+ // Create a temporary file for the input video
26
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "ffmpeg-input-"));
27
+ inputFilePath = path.join(tempDir, "temp.mp4");
28
+
29
+ // Write the base64 data to the temporary file
30
+ await writeFile(inputFilePath, inputBuffer);
31
+ } else {
32
+ // Verify that the input file exists
33
+ if (!(await stat(inputFilePath)).isFile()) {
34
+ throw new Error(`Input video file does not exist: ${inputFilePath}`);
35
+ }
36
+ }
37
+
38
+ // If no output path is provided, create a temporary file for the output
39
+ if (!outputAudioPath) {
40
+ const tempDir = await mkdtemp(path.join(tmpdir(), "ffmpeg-output-"));
41
+ outputAudioPath = path.join(tempDir, `${path.parse(inputFilePath).name}.mp3`);
42
+ }
43
+
44
+ return new Promise((resolve, reject) => {
45
+ ffmpeg(inputFilePath)
46
+ .toFormat("mp3")
47
+ .on("error", (err) => {
48
+ reject(new Error(`Error converting video to audio: ${err.message}`));
49
+ })
50
+ .on("end", async () => {
51
+ if (asBase64) {
52
+ try {
53
+ const audioBuffer = await readFile(outputAudioPath);
54
+ const audioBase64 = `data:audio/mp3;base64,${audioBuffer.toString("base64")}`;
55
+ resolve(audioBase64);
56
+ } catch (error) {
57
+ reject(new Error(`Error reading audio file: ${(error as Error).message}`));
58
+ }
59
+ } else {
60
+ resolve(outputAudioPath);
61
+ }
62
+ })
63
+ .save(outputAudioPath);
64
+ });
65
+ }
src/core/ffmpeg/convertMp4ToWebm.mts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { mkdtemp, stat, writeFile, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { tmpdir } from "node:os";
6
+ import { Buffer } from "node:buffer";
7
+
8
+ import ffmpeg from "fluent-ffmpeg";
9
+
10
+ export async function convertMp4ToWebm({
11
+ input,
12
+ outputVideoPath,
13
+ asBase64 = false,
14
+ }: {
15
+ input: string;
16
+ outputVideoPath?: string;
17
+ asBase64?: boolean;
18
+ }): Promise<string> {
19
+ let inputFilePath = input;
20
+
21
+ // Check if the input is a base64 string
22
+ if (input.startsWith("data:")) {
23
+ const base64Data = input.split(",")[1];
24
+ const inputBuffer = Buffer.from(base64Data, "base64");
25
+
26
+ // Create a temporary file for the input video
27
+ const tempDir = await mkdtemp(path.join(tmpdir(), "ffmpeg-input-"));
28
+ inputFilePath = path.join(tempDir, "temp.mp4");
29
+
30
+ // Write the base64 data to the temporary file
31
+ await writeFile(inputFilePath, inputBuffer);
32
+ } else {
33
+ // Verify that the input file exists
34
+ const inputFileStats = await stat(inputFilePath);
35
+ if (!inputFileStats.isFile()) {
36
+ throw new Error(`Input video file does not exist: ${inputFilePath}`);
37
+ }
38
+ }
39
+
40
+ // If no output path is provided, create a temporary file for the output
41
+ if (!outputVideoPath) {
42
+ const tempDir = await mkdtemp(path.join(tmpdir(), "ffmpeg-output-"));
43
+ outputVideoPath = path.join(tempDir, `${path.parse(inputFilePath).name}.webm`);
44
+ }
45
+
46
+ return new Promise((resolve, reject) => {
47
+ ffmpeg(inputFilePath)
48
+ .toFormat("webm")
49
+ .videoCodec("libvpx")
50
+ .addOption("-b:v", "1000k") // ~ 400 kB for 3 seconds of video
51
+ .audioCodec("libvorbis")
52
+ .on("error", (err) => {
53
+ reject(new Error(`Error converting video to WebM: ${err.message}`));
54
+ })
55
+ .on("end", async () => {
56
+ if (asBase64) {
57
+ try {
58
+ const videoBuffer = await readFile(outputVideoPath);
59
+ const videoBase64 = `data:video/webm;base64,${videoBuffer.toString("base64")}`;
60
+ resolve(videoBase64);
61
+ } catch (error) {
62
+ reject(new Error(`Error reading video file: ${(error as Error).message}`));
63
+ }
64
+ } else {
65
+ resolve(outputVideoPath);
66
+ }
67
+ })
68
+ .save(outputVideoPath);
69
+ });
70
+ }
src/core/ffmpeg/createTextOverlayImage.mts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { TextOverlayFont, TextOverlayFontWeight, TextOverlayStyle, getCssStyle } from "../utils/getCssStyle.mts"
3
+ import { htmlToBase64Png } from "../converters/htmlToBase64Png.mts"
4
+
5
+ // generate a PNG overlay using HTML
6
+ export async function createTextOverlayImage({
7
+ text = "",
8
+ textStyle = "outline",
9
+ fontFamily = "Montserrat",
10
+ fontSize = 10,
11
+ fontWeight = 600,
12
+ rotation = 0,
13
+ width = 1024,
14
+ height = 576
15
+ }: {
16
+ text?: string
17
+ textStyle?: TextOverlayStyle
18
+ fontFamily?: TextOverlayFont
19
+ fontSize?: number
20
+ fontWeight?: TextOverlayFontWeight
21
+ rotation?: number
22
+ width?: number
23
+ height?: number
24
+ }): Promise<{
25
+ filePath: string
26
+ buffer: Buffer
27
+ }> {
28
+
29
+
30
+ const html = `<html>
31
+ <head>${getCssStyle({
32
+ fontFamily,
33
+ fontSize,
34
+ fontWeight: 600,
35
+ })}</head>
36
+ <body>
37
+
38
+ <!-- main content block (will be center in the middle of the screen) -->
39
+ <div class="content">
40
+
41
+ <!-- main line of text -->
42
+ <p class="${textStyle}">
43
+ ${text}
44
+ </p>
45
+ </div>
46
+
47
+ </body>
48
+ </html>`
49
+
50
+ const result = await htmlToBase64Png({
51
+ html,
52
+ width,
53
+ height,
54
+ })
55
+
56
+ return result;
57
+ };
src/core/ffmpeg/createVideoFromFrames.mts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs"
2
+ import { writeFile, readFile } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ import ffmpeg from "fluent-ffmpeg"
7
+ import { v4 as uuidv4 } from "uuid"
8
+
9
+ import { getMediaInfo } from "./getMediaInfo.mts"
10
+
11
+ export async function createVideoFromFrames({
12
+ inputFramesDirectory,
13
+ framesFilePattern,
14
+ outputVideoPath,
15
+ framesPerSecond = 25,
16
+
17
+ // there isn't a lot of advantage for us to add film grain because:
18
+ // 1. I actually can't tell the different, probably because it's in HD, and so tiny
19
+ // 2. We want a neat "4K video from the 2020" look, not a quality from 30 years ago
20
+ // 3. grain has too much entropy and cannot be compressed, so it multiplies by 5 the size weight
21
+ grainAmount = 0, // Optional parameter for film grain (eg. 10)
22
+
23
+ inputVideoToUseAsAudio, // Optional parameter for audio input (need to be a mp4, but it can be a base64 data URI or a file path)
24
+
25
+ debug = false,
26
+
27
+ asBase64 = false,
28
+ }: {
29
+ inputFramesDirectory: string;
30
+
31
+ // the ffmpeg file pattern to use
32
+ framesFilePattern?: string;
33
+
34
+ outputVideoPath?: string;
35
+ framesPerSecond?: number;
36
+ grainAmount?: number; // Values can range between 0 and higher for the desired amount
37
+ inputVideoToUseAsAudio?: string; // Optional parameter for audio input (need to be a mp4, but it can be a base64 data URI or a file path)
38
+ debug?: boolean;
39
+ asBase64?: boolean;
40
+ }): Promise<string> {
41
+ // Ensure the input directory exists
42
+ await fs.access(inputFramesDirectory);
43
+
44
+
45
+ // Construct the input frame pattern
46
+ const inputFramePattern = path.join(inputFramesDirectory, framesFilePattern);
47
+
48
+
49
+ // Create a temporary working directory
50
+ const tempDir = path.join(os.tmpdir(), uuidv4());
51
+ await fs.mkdir(tempDir);
52
+
53
+
54
+ let inputVideoToUseAsAudioFilePath = "";
55
+ if (inputVideoToUseAsAudio.startsWith('data:')) {
56
+ // Extract the base64 content and decode it to a temporary file
57
+ const base64Content = inputVideoToUseAsAudio.split(';base64,').pop();
58
+ if (!base64Content) {
59
+ throw new Error('Invalid base64 input provided');
60
+ }
61
+ inputVideoToUseAsAudioFilePath = path.join(tempDir, `${uuidv4()}_audio_input.mp4`);
62
+ await writeFile(inputVideoToUseAsAudioFilePath, base64Content, 'base64');
63
+ } else {
64
+ inputVideoToUseAsAudioFilePath = inputVideoToUseAsAudio;
65
+ }
66
+
67
+ if (debug) {
68
+ console.log(" createVideoFromFraes(): inputVideoToUseAsAudioFilePath = ", inputVideoToUseAsAudioFilePath)
69
+ }
70
+
71
+
72
+ let canUseInputVideoForAudio = false
73
+ // Also, if provided, check that the audio source file exists
74
+ if (inputVideoToUseAsAudioFilePath) {
75
+ try {
76
+ await fs.access(inputVideoToUseAsAudioFilePath)
77
+ const info = await getMediaInfo(inputVideoToUseAsAudioFilePath)
78
+ if (info.hasAudio) {
79
+ canUseInputVideoForAudio = true
80
+ }
81
+ } catch (err) {
82
+ if (debug) {
83
+ console.log(" createVideoFromFrames(): warning: input video has no audio, so we are not gonna use that")
84
+ }
85
+ }
86
+ }
87
+
88
+ const outputVideoFilePath = outputVideoPath ?? path.join(tempDir, `${uuidv4()}.mp4`);
89
+
90
+ if (debug) {
91
+ console.log(" createVideoFromFrames(): outputOptions:", [
92
+ // by default ffmpeg doesn't tell us why it fails to convet
93
+ // so we need to force it to spit everything out
94
+ "-loglevel", "debug",
95
+
96
+ "-pix_fmt", "yuv420p",
97
+ "-c:v", "libx264",
98
+ "-r", `${framesPerSecond}`,
99
+
100
+ // from ffmpeg doc: "Consider 17 or 18 to be visually lossless or nearly so;
101
+ // it should look the same or nearly the same as the input."
102
+ "-crf", "17",
103
+ ])
104
+ }
105
+
106
+ return new Promise<string>((resolve, reject) => {
107
+ const command = ffmpeg()
108
+ .input(inputFramePattern)
109
+ .inputFPS(framesPerSecond)
110
+ .outputOptions([
111
+ // by default ffmpeg doesn't tell us why it fails to convet
112
+ // so we need to force it to spit everything out
113
+ "-loglevel", "debug",
114
+
115
+ "-pix_fmt", "yuv420p",
116
+ "-c:v", "libx264",
117
+ "-r", `${framesPerSecond}`,
118
+ "-crf", "18",
119
+ ]);
120
+
121
+
122
+ // If an input video for audio is provided, add it as an input for the ffmpeg command
123
+ if (canUseInputVideoForAudio) {
124
+ if (debug) {
125
+ console.log(" createVideoFromFrames(): adding audio as input:", inputVideoToUseAsAudioFilePath)
126
+ }
127
+ command.addInput(inputVideoToUseAsAudioFilePath);
128
+ command.outputOptions([
129
+ "-map", "0:v", // Map video from the frames
130
+ "-map", "1:a", // Map audio from the input video
131
+ "-shortest" // Ensure output video duration is the shortest of the combined inputs
132
+ ]);
133
+ }
134
+
135
+ // Apply grain effect using the geq filter if grainAmount is specified
136
+ if (grainAmount != null && grainAmount > 0) {
137
+ if (debug) {
138
+ console.log(" createVideoFromFrames(): adding grain:", grainAmount)
139
+ }
140
+ command.complexFilter([
141
+ {
142
+ filter: "geq",
143
+ options: `lum='lum(X,Y)':cr='cr(X,Y)+(random(1)-0.5)*${grainAmount}':cb='cb(X,Y)+(random(1)-0.5)*${grainAmount}'`
144
+ }
145
+ ]);
146
+ }
147
+
148
+ command.save(outputVideoFilePath)
149
+ .on("error", (err) => reject(err))
150
+ .on("end", async () => {
151
+ if (debug) {
152
+ console.log(" createVideoFromFrames(): outputVideoFilePath: ", outputVideoFilePath)
153
+ }
154
+ if (!asBase64) {
155
+ resolve(outputVideoFilePath)
156
+ return
157
+ }
158
+ // Convert the output file to a base64 string
159
+ try {
160
+ const videoBuffer = await readFile(outputVideoFilePath);
161
+ const videoBase64 = `data:video/mp4;base64,${videoBuffer.toString('base64')}`;
162
+ console.log(" createVideoFromFrames(): output base64: ", videoBase64.slice(0, 120))
163
+ resolve(videoBase64);
164
+ } catch (error) {
165
+ reject(new Error(`Error loading the video file: ${error}`));
166
+ } finally {
167
+ // Clean up temporary files
168
+ await fs.rm(tempDir, { recursive: true });
169
+ }
170
+ });
171
+ });
172
+ }
173
+
src/core/ffmpeg/cropBase64Video.mts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import ffmpeg from "fluent-ffmpeg";
6
+
7
+ export async function cropBase64Video({
8
+ base64Video,
9
+ width,
10
+ height,
11
+ }: {
12
+ base64Video: string;
13
+ width: number;
14
+ height: number;
15
+ }): Promise<string> {
16
+ // Create a buffer from the base64 string, skipping the data URI scheme
17
+ const base64Data = base64Video.replace(/^data:video\/mp4;base64,/, "");
18
+ const videoBuffer = Buffer.from(base64Data, "base64");
19
+
20
+ // Create a temporary file for the input video
21
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-crop-input-"));
22
+ const inputVideoPath = path.join(tempDir, `input.mp4`);
23
+ await fs.writeFile(inputVideoPath, videoBuffer);
24
+
25
+ // Create a temporary file for the output video
26
+ const outputTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-crop-output-"));
27
+ const outputVideoPath = path.join(outputTempDir, `output-cropped.mp4`);
28
+
29
+ // Return a promise that resolves with the path to the output cropped video file
30
+ return new Promise((resolve, reject) => {
31
+ ffmpeg(inputVideoPath)
32
+ .ffprobe((err, metadata) => {
33
+ if (err) {
34
+ reject(new Error(`Error reading video metadata: ${err.message}`));
35
+ return;
36
+ }
37
+
38
+ const videoStream = metadata.streams.find(s => s.codec_type === "video");
39
+ if (!videoStream) {
40
+ reject(new Error(`Cannot find video stream in file: ${inputVideoPath}`));
41
+ return;
42
+ }
43
+
44
+ const { width: inWidth, height: inHeight } = videoStream;
45
+ const x = Math.floor((inWidth - width) / 2);
46
+ const y = Math.floor((inHeight - height) / 2);
47
+
48
+ ffmpeg(inputVideoPath)
49
+ .outputOptions([
50
+ `-vf crop=${width}:${height}:${x}:${y}`
51
+ ])
52
+ .on("error", (err) => {
53
+ reject(new Error(`Error cropping video: ${err.message}`));
54
+ })
55
+ .on("end", () => {
56
+ resolve(outputVideoPath);
57
+ })
58
+ .on('codecData', (data) => {
59
+ console.log('Input is ' + data.audio + ' audio ' +
60
+ 'with ' + data.video + ' video');
61
+ })
62
+ .save(outputVideoPath);
63
+ });
64
+ });
65
+ }
src/core/ffmpeg/cropVideo.mts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs";
2
+ // import { writeFile, readFile } from 'node:fs/promises';
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import ffmpeg from "fluent-ffmpeg";
7
+
8
+ export async function cropVideo({
9
+ inputVideoPath,
10
+ width,
11
+ height,
12
+ debug = false,
13
+ asBase64 = false,
14
+ }: {
15
+ inputVideoPath: string
16
+ width: number
17
+ height: number
18
+ debug?: boolean
19
+ asBase64?: boolean
20
+ }): Promise<string> {
21
+ // Verify that the input file exists
22
+ if (!(await fs.stat(inputVideoPath)).isFile()) {
23
+ throw new Error(`Input video file does not exist: ${inputVideoPath}`);
24
+ }
25
+
26
+ // Create a temporary file for the output
27
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-crop-"));
28
+ const outputVideoPath = path.join(tempDir, `${path.parse(inputVideoPath).name}-cropped.mp4`);
29
+
30
+ // Return a promise that resolves with the path to the output cropped video file
31
+ return new Promise((resolve, reject) => {
32
+ ffmpeg(inputVideoPath)
33
+ .ffprobe((err, metadata) => {
34
+ if (err) {
35
+ reject(new Error(`Error reading video metadata: ${err.message}`));
36
+ return;
37
+ }
38
+
39
+ const videoStream = metadata.streams.find(s => s.codec_type === "video");
40
+ if (!videoStream) {
41
+ reject(new Error(`Cannot find video stream in file: ${inputVideoPath}`));
42
+ return;
43
+ }
44
+
45
+ const { width: inWidth, height: inHeight } = videoStream;
46
+ const x = Math.floor((inWidth - width) / 2);
47
+ const y = Math.floor((inHeight - height) / 2);
48
+
49
+ ffmpeg(inputVideoPath)
50
+ .outputOptions([
51
+ `-vf crop=${width}:${height}:${x}:${y}`
52
+ ])
53
+ .on("error", (err) => {
54
+ reject(new Error(`Error cropping video: ${err.message}`));
55
+ })
56
+ .on("end", async () => {
57
+ if (!asBase64) {
58
+ resolve(outputVideoPath)
59
+ return
60
+ }
61
+ // Convert the output file to a base64 string
62
+ try {
63
+ const videoBuffer = await fs.readFile(outputVideoPath);
64
+ const videoBase64 = `data:video/mp4;base64,${videoBuffer.toString('base64')}`;
65
+ resolve(videoBase64);
66
+ } catch (error) {
67
+ reject(new Error(`Error loading the video file: ${error}`));
68
+ } finally {
69
+ // Clean up temporary files
70
+ await fs.rm(tempDir, { recursive: true });
71
+ }
72
+ })
73
+ .save(outputVideoPath);
74
+ });
75
+ });
76
+ }
src/core/ffmpeg/getMediaInfo.mts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ffmpeg from "fluent-ffmpeg";
2
+
3
+ import { tmpdir } from "node:os";
4
+ import { promises as fs } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export type MediaMetadata = {
8
+ durationInSec: number;
9
+ durationInMs: number;
10
+ hasAudio: boolean;
11
+ };
12
+
13
+ /**
14
+ * Get the media info of a base64 or file path
15
+ * @param input
16
+ * @returns
17
+ */
18
+ export async function getMediaInfo(input: string): Promise<MediaMetadata> {
19
+ // If the input is a base64 string
20
+ if (input.startsWith("data:")) {
21
+ // Extract the base64 content
22
+ const base64Content = input.split(";base64,").pop();
23
+ if (!base64Content) {
24
+ throw new Error("Invalid base64 data");
25
+ }
26
+
27
+ // Decode the base64 content to a buffer
28
+ const buffer = Buffer.from(base64Content, 'base64');
29
+
30
+ // Generate a temporary file name
31
+ const tempFileName = join(tmpdir(), `temp-media-${Date.now()}`);
32
+
33
+ // Write the buffer to a temporary file
34
+ await fs.writeFile(tempFileName, buffer);
35
+
36
+ // Get metadata from the temporary file then delete the file
37
+ try {
38
+ return await getMetaDataFromPath(tempFileName);
39
+ } finally {
40
+ await fs.unlink(tempFileName);
41
+ }
42
+ }
43
+
44
+ // If the input is a path to the file
45
+ return await getMetaDataFromPath(input);
46
+ }
47
+
48
+ async function getMetaDataFromPath(filePath: string): Promise<MediaMetadata> {
49
+ return new Promise((resolve, reject) => {
50
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
51
+
52
+ let results = {
53
+ durationInSec: 0,
54
+ durationInMs: 0,
55
+ hasAudio: false,
56
+ }
57
+
58
+ if (err) {
59
+ console.error("getMediaInfo(): failed to analyze the source (might happen with empty files)")
60
+ // reject(err);
61
+ resolve(results);
62
+ return;
63
+ }
64
+
65
+ try {
66
+ results.durationInSec = metadata?.format?.duration || 0;
67
+ results.durationInMs = results.durationInSec * 1000;
68
+ results.hasAudio = (metadata?.streams || []).some((stream) => stream.codec_type === 'audio');
69
+
70
+ } catch (err) {
71
+ console.error(`getMediaInfo(): failed to analyze the source (might happen with empty files)`)
72
+ results.durationInSec = 0
73
+ results.durationInMs = 0
74
+ results.hasAudio = false
75
+ }
76
+ resolve(results);
77
+ });
78
+ });
79
+ }
src/core/ffmpeg/scaleVideo.mts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs/promises';
2
+ import { writeFile, readFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { v4 as uuidv4 } from "uuid";
7
+ import ffmpeg from 'fluent-ffmpeg';
8
+
9
+ export type ScaleVideoParams = {
10
+ input: string;
11
+ height: number;
12
+ debug?: boolean;
13
+ asBase64?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Rescale a video (either file or base 64) to a given height.
18
+ * This returns a base64 video.
19
+ *
20
+ * Some essential things to note in this implementation:
21
+ *
22
+ * If the input is a valid base64 string, it gets decoded and stored as a temporary .mp4 file.
23
+ * The ffmpeg.outputOptions includes the arguments for setting the output video height and keeping the aspect ratio intact. The -1 in scale=-1:${height} tells ffmpeg to preserve aspect ratio based on the height.
24
+ * The output is a libx264-encoded MP4 video, matching typical browser support standards.
25
+ * Upon completion, the temporary output file is read into a buffer, converted to a base64 string with the correct prefix, and then cleaned up by removing temporary files.
26
+ * To call this function with desired input and height, you'd use it similarly to the provided convertMp4ToMp3 function example, being mindful that input must be a file path or properly-formatted base64 string and height is a number representing the new height of the video.
27
+ *
28
+ * Enter your message...
29
+ *
30
+ * @param param0
31
+ * @returns
32
+ */
33
+ export async function scaleVideo({
34
+ input,
35
+ height,
36
+ asBase64 = false,
37
+ debug = false
38
+ }: ScaleVideoParams): Promise<string> {
39
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ffmpeg-"));
40
+ const tempOutPath = path.join(tempDir, `${uuidv4()}.mp4`);
41
+
42
+ let inputPath;
43
+ if (input.startsWith('data:')) {
44
+ // Extract the base64 content and decode it to a temporary file
45
+ const base64Content = input.split(';base64,').pop();
46
+ if (!base64Content) {
47
+ throw new Error('Invalid base64 input provided');
48
+ }
49
+ inputPath = path.join(tempDir, `${uuidv4()}.mp4`);
50
+ await writeFile(inputPath, base64Content, 'base64');
51
+ } else {
52
+ inputPath = input;
53
+ }
54
+
55
+ if (debug) {
56
+ console.log("inputPath:", inputPath)
57
+ }
58
+
59
+ // Return a promise that resolves with the base64 string of the output video
60
+ return new Promise((resolve, reject) => {
61
+ ffmpeg(inputPath)
62
+ .outputOptions([
63
+ '-vf', `scale=-1:${height}`,
64
+ '-c:v', 'libx264',
65
+ '-preset', 'fast',
66
+ '-crf', '22'
67
+ ])
68
+ .on('error', (err) => {
69
+ reject(new Error(`Error scaling the video: ${err.message}`));
70
+ })
71
+ .on('end', async () => {
72
+ if (!asBase64) {
73
+ resolve(tempOutPath)
74
+ return
75
+ }
76
+ // Convert the output file to a base64 string
77
+ try {
78
+ const videoBuffer = await readFile(tempOutPath);
79
+ const videoBase64 = `data:video/mp4;base64,${videoBuffer.toString('base64')}`;
80
+ resolve(videoBase64);
81
+ } catch (error) {
82
+ reject(new Error(`Error loading the video file: ${error}`));
83
+ } finally {
84
+ // Clean up temporary files
85
+ await fs.rm(tempDir, { recursive: true });
86
+ }
87
+ })
88
+ .save(tempOutPath);
89
+ });
90
+ }
src/core/files/deleteFileWithName.mts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export const deleteFilesWithName = async (dir: string, name: string, debug?: boolean) => {
5
+ for (const file of await fs.readdir(dir)) {
6
+ if (file.includes(name)) {
7
+ const filePath = path.join(dir, file)
8
+ try {
9
+ await fs.unlink(filePath)
10
+ } catch (err) {
11
+ if (debug) {
12
+ console.error(`failed to unlink file in ${filePath}: ${err}`)
13
+ }
14
+ }
15
+ }
16
+ }
17
+ }
src/core/files/downloadFileAsBase64.mts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { lookup } from "mime-types"
2
+
3
+ export const downloadFileAsBase64 = async (remoteUrl: string): Promise<string> => {
4
+ // const controller = new AbortController()
5
+
6
+ // download the file
7
+ const response = await fetch(remoteUrl, {
8
+ // signal: controller.signal
9
+ })
10
+
11
+ // get as Buffer
12
+ const arrayBuffer = await response.arrayBuffer()
13
+ const buffer = Buffer.from(arrayBuffer)
14
+
15
+ // convert it to base64
16
+ const base64 = buffer.toString('base64')
17
+
18
+
19
+ const res = lookup(remoteUrl)
20
+ let contentType = res.toString()
21
+ if (typeof res === "boolean" && res === false) {
22
+ contentType = response.headers.get('content-type')
23
+ }
24
+
25
+ const assetUrl = `data:${contentType};base64,${base64}`
26
+ return assetUrl
27
+ };
src/core/files/readJpegFileToBase64.mts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "fs"
2
+
3
+ export async function readJpegFileToBase64(filePath: string): Promise<string> {
4
+ try {
5
+ // Read the file's content as a Buffer
6
+ const fileBuffer = await fs.readFile(filePath);
7
+
8
+ // Convert the buffer to a base64 string
9
+ const base64 = fileBuffer.toString('base64');
10
+
11
+ // Prefix the base64 string with the Data URI scheme for PNG images
12
+ return `data:image/jpeg;base64,${base64}`;
13
+ } catch (error) {
14
+ // Handle errors (e.g., file not found, no permissions, etc.)
15
+ console.error(error);
16
+ throw error;
17
+ }
18
+ }
src/core/files/readMp3FileToBase64.mts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "fs"
2
+
3
+ export async function readMp3FileToBase64(filePath: string): Promise<string> {
4
+ try {
5
+ // Read the file's content as a Buffer
6
+ const fileBuffer = await fs.readFile(filePath);
7
+
8
+ // Convert the buffer to a base64 string
9
+ const base64 = fileBuffer.toString('base64');
10
+
11
+ // Prefix the base64 string with the Data URI scheme for PNG images
12
+ return `data:audio/mp3;base64,${base64}`;
13
+ } catch (error) {
14
+ // Handle errors (e.g., file not found, no permissions, etc.)
15
+ console.error(error);
16
+ throw error;
17
+ }
18
+ }
src/core/files/readMp4FileToBase64.mts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "fs"
2
+
3
+ export async function readMp4FileToBase64(filePath: string): Promise<string> {
4
+ try {
5
+ // Read the file's content as a Buffer
6
+ const fileBuffer = await fs.readFile(filePath);
7
+
8
+ // Convert the buffer to a base64 string
9
+ const base64 = fileBuffer.toString('base64');
10
+
11
+ // Prefix the base64 string with the Data URI scheme for PNG images
12
+ return `data:video/mp4;base64,${base64}`;
13
+ } catch (error) {
14
+ // Handle errors (e.g., file not found, no permissions, etc.)
15
+ console.error(error);
16
+ throw error;
17
+ }
18
+ }
src/core/files/readPlainText.mts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { promises as fs } from "fs"
2
+
3
+ export async function readPlainText(filePath: string): Promise<string> {
4
+ try {
5
+ const plainText = await fs.readFile(filePath, "utf-8");
6
+
7
+ return plainText;
8
+ } catch (error) {
9
+ // Handle errors (e.g., file not found, no permissions, etc.)
10
+ console.error(error);
11
+ throw error;
12
+ }
13
+ }