Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Commit
•
2cae2a9
0
Parent(s):
initial commit 🎬
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +6 -0
- .env +3 -0
- .gitignore +13 -0
- .nvmrc +1 -0
- Dockerfile +50 -0
- LICENSE.txt +201 -0
- README.md +38 -0
- package-lock.json +0 -0
- package.json +40 -0
- src/core/base64/addBase64.mts +51 -0
- src/core/base64/dataUriToBlob.mts +15 -0
- src/core/base64/extractBase64.mts +36 -0
- src/core/clap/getClapAssetSourceType.mts +25 -0
- src/core/clap/parseClap.mts +320 -0
- src/core/clap/types.mts +203 -0
- src/core/converters/blobToWebp.mts +5 -0
- src/core/converters/bufferToJpeg.mts +5 -0
- src/core/converters/bufferToMp3.mts +5 -0
- src/core/converters/bufferToMp4.mts +5 -0
- src/core/converters/bufferToPng.mts +5 -0
- src/core/converters/bufferToWav.mts +5 -0
- src/core/converters/bufferToWebp.mts +5 -0
- src/core/converters/convertImageTo.mts +31 -0
- src/core/converters/convertImageToJpeg.mts +27 -0
- src/core/converters/convertImageToOriginal.mts +6 -0
- src/core/converters/convertImageToPng.mts +23 -0
- src/core/converters/convertImageToWebp.mts +41 -0
- src/core/converters/htmlToBase64Png.mts +78 -0
- src/core/converters/imageFormats.mts +1 -0
- src/core/ffmpeg/addImageToVideo.mts +50 -0
- src/core/ffmpeg/addTextToVideo.mts +23 -0
- src/core/ffmpeg/concatenateAudio.mts +122 -0
- src/core/ffmpeg/concatenateVideos.mts +61 -0
- src/core/ffmpeg/concatenateVideosAndMergeAudio.mts +130 -0
- src/core/ffmpeg/concatenateVideosWithAudio.mts +158 -0
- src/core/ffmpeg/convertAudioToWav.mts +69 -0
- src/core/ffmpeg/convertMp4ToMp3.mts +65 -0
- src/core/ffmpeg/convertMp4ToWebm.mts +70 -0
- src/core/ffmpeg/createTextOverlayImage.mts +57 -0
- src/core/ffmpeg/createVideoFromFrames.mts +173 -0
- src/core/ffmpeg/cropBase64Video.mts +65 -0
- src/core/ffmpeg/cropVideo.mts +76 -0
- src/core/ffmpeg/getMediaInfo.mts +79 -0
- src/core/ffmpeg/scaleVideo.mts +90 -0
- src/core/files/deleteFileWithName.mts +17 -0
- src/core/files/downloadFileAsBase64.mts +27 -0
- src/core/files/readJpegFileToBase64.mts +18 -0
- src/core/files/readMp3FileToBase64.mts +18 -0
- src/core/files/readMp4FileToBase64.mts +18 -0
- 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 |
+
}
|