Compare commits
75 commits
Author | SHA1 | Date | |
---|---|---|---|
d5bb7a4bdc | |||
5e1f5e8829 | |||
5808bff395 | |||
330515d6b4 | |||
7662150b89 | |||
b6fb0fa184 | |||
6e56452f78 | |||
680ea8f4e5 | |||
13c03559d0 | |||
f9129b2351 | |||
9a58e39bf8 | |||
2fdb653496 | |||
cbdf55335a | |||
6934012c11 | |||
52c89dc95a | |||
4aefb5a647 | |||
14daa1c9f9 | |||
78609dec9a | |||
1dfe835587 | |||
b6bd1be12e | |||
4f4711cf31 | |||
e55886a0df | |||
867544f12e | |||
2ba0f6a075 | |||
17d961bc0d | |||
be787b6c9c | |||
fad24597fd | |||
8147ffd231 | |||
5b2d6653dc | |||
97a7268d4a | |||
677c35a6fd | |||
b11baf1358 | |||
8b57b97c80 | |||
1e7f5f95cd | |||
01e0758b6a | |||
7b1681d85d | |||
9ae95fefb6 | |||
05b650dfd7 | |||
410a4eaf96 | |||
3798b3382a | |||
6de519c980 | |||
98f415ade7 | |||
9a4b3142ff | |||
d323915aed | |||
0de4f35311 | |||
cae7b9b99b | |||
4612cbdfaa | |||
0d98ab21f0 | |||
9d87c61a39 | |||
cd705c1153 | |||
f5ec5db460 | |||
3c0169f6bb | |||
881ab99410 | |||
8df868eff3 | |||
8e2b72c431 | |||
fead583ce9 | |||
3aeb9dd8be | |||
d7d30ac6bf | |||
5c50b33251 | |||
f4adda912a | |||
c288f55ed0 | |||
27e986d53b | |||
fad0d1ec6c | |||
083bcb07c2 | |||
5746939c06 | |||
27c7cb3c7d | |||
f2f3f67d10 | |||
5cc91d712f | |||
2882fb286a | |||
0865076849 | |||
270233ca5c | |||
24ea4ebe07 | |||
371071ca47 | |||
feb8596bfc | |||
268c4b3af7 |
21 changed files with 1962 additions and 642 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
**/target/
|
||||||
|
23*/
|
||||||
|
24*/
|
18
.forgejo/workflows/webhook.yml
Normal file
18
.forgejo/workflows/webhook.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
name: Trigger quay.io Webhook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: alpine-latest
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
apk add ca-certificates curl
|
||||||
|
curl -D - --fail-with-body -X POST -H 'Content-Type: application/json' --data '{
|
||||||
|
"commit": "${{github.sha}}",
|
||||||
|
"ref": "${{github.ref}}",
|
||||||
|
"default_branch": "main"
|
||||||
|
}' 'https://$token:${{secrets.quay_token}}@quay.io/webhooks/push/trigger/f21fe844-3a4b-43b0-a92f-7871d7d7ea68'
|
||||||
|
shell: ash -eo pipefail {0}
|
31
230101/project.toml
Normal file
31
230101/project.toml
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
[lecture]
|
||||||
|
course = "23ws-malo2"
|
||||||
|
label = "Mathematische Logik II"
|
||||||
|
docent = "Prof. E. Grädel"
|
||||||
|
date = "230101"
|
||||||
|
|
||||||
|
[source]
|
||||||
|
files = ["C01.mp4", "C02.mp4", "C03.mp4"]
|
||||||
|
stereo = false
|
||||||
|
start = "1"
|
||||||
|
end = "12"
|
||||||
|
fast = [["6", "8"], ["10", "11"]]
|
||||||
|
questions = [
|
||||||
|
["1.5", "3", "Hallo liebes Publikum. Ich habe leider meine Frage vergessen. Bitte entschuldigt die Störung."],
|
||||||
|
["3.5", "5", "Ah jetzt weiß ich es wieder. Meine Frage war: Was war meine Frage?"]
|
||||||
|
]
|
||||||
|
|
||||||
|
[source.metadata]
|
||||||
|
source_duration = "12.53000"
|
||||||
|
source_fps = "50/1"
|
||||||
|
source_tbn = "1/1000"
|
||||||
|
source_res = "FullHD"
|
||||||
|
source_sample_rate = 48000
|
||||||
|
|
||||||
|
[progress]
|
||||||
|
preprocessed = false
|
||||||
|
asked_start_end = true
|
||||||
|
asked_fast = true
|
||||||
|
asked_questions = true
|
||||||
|
rendered = false
|
||||||
|
transcoded = []
|
|
@ -17,7 +17,7 @@ function render_clip() {
|
||||||
aevalsrc=sin($freq*2*PI*t):s=48000,pan=stereo|c0=c0|c1=c0[a]
|
aevalsrc=sin($freq*2*PI*t):s=48000,pan=stereo|c0=c0|c1=c0[a]
|
||||||
" \
|
" \
|
||||||
-map "[v]" -map "[a]" \
|
-map "[v]" -map "[a]" \
|
||||||
-c:v h264_vaapi -r 25 -t 4 \
|
-c:v h264_vaapi -r 50 -t 4 \
|
||||||
-c:a aac -b:a 128000 \
|
-c:a aac -b:a 128000 \
|
||||||
"$out"
|
"$out"
|
||||||
}
|
}
|
||||||
|
|
11
Cargo.toml
11
Cargo.toml
|
@ -5,14 +5,23 @@ name = "render_video"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
publish = false
|
publish = false
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "EPL-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
camino = "1.1"
|
camino = "1.1"
|
||||||
|
console = "0.15"
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
indexmap = "1.9"
|
fontconfig = "0.8"
|
||||||
|
harfbuzz_rs = "2.0"
|
||||||
|
indexmap = "2.2"
|
||||||
rational = "1.5"
|
rational = "1.5"
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
serde = { version = "1.0.188", features = ["derive"] }
|
||||||
serde_with = "3.4"
|
serde_with = "3.4"
|
||||||
svgwriter = "0.1"
|
svgwriter = "0.1"
|
||||||
toml = { package = "basic-toml", version = "0.1.4" }
|
toml = { package = "basic-toml", version = "0.1.4" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["mem_limit"]
|
||||||
|
mem_limit = []
|
||||||
|
vaapi = []
|
||||||
|
|
64
Dockerfile
Normal file
64
Dockerfile
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
ARG ffmpeg_ver=7.0
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/local/src/render_video
|
||||||
|
COPY LICENSE /usr/local/src/render_video/LICENSE
|
||||||
|
COPY Cargo.toml /usr/local/src/render_video/Cargo.toml
|
||||||
|
COPY src /usr/local/src/render_video/src
|
||||||
|
COPY assets /usr/local/src/render_video/assets
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
dav1d fontconfig freetype harfbuzz librsvg libva lilv-libs opus svt-av1 x264-libs x265 \
|
||||||
|
font-noto inkscape libarchive-tools libgcc \
|
||||||
|
&& apk add --no-cache --virtual .build-deps \
|
||||||
|
build-base cargo pkgconf \
|
||||||
|
dav1d-dev fontconfig-dev freetype-dev harfbuzz-dev librsvg-dev libva-dev lilv-dev opus-dev svt-av1-dev x264-dev x265-dev \
|
||||||
|
# build the render_video project
|
||||||
|
&& cargo install --path /usr/local/src/render_video --root /usr/local --no-default-features \
|
||||||
|
&& rm -rf ~/.cargo \
|
||||||
|
# we install ffmpeg ourselves to get libsvtav1 support which is not part of the alpine package \
|
||||||
|
&& wget -q https://ffmpeg.org/releases/ffmpeg-${ffmpeg_ver}.tar.bz2 \
|
||||||
|
&& tar xfa ffmpeg-${ffmpeg_ver}.tar.bz2 \
|
||||||
|
&& cd ffmpeg-${ffmpeg_ver} \
|
||||||
|
&& ./configure \
|
||||||
|
--prefix=/usr/local \
|
||||||
|
--disable-asm \
|
||||||
|
--disable-librtmp \
|
||||||
|
--disable-lzma \
|
||||||
|
--disable-static \
|
||||||
|
--enable-avfilter \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-libdav1d \
|
||||||
|
--enable-libfontconfig \
|
||||||
|
--enable-libfreetype \
|
||||||
|
--enable-libharfbuzz \
|
||||||
|
--enable-libopus \
|
||||||
|
--enable-librsvg \
|
||||||
|
--enable-libsvtav1 \
|
||||||
|
--enable-libx264 \
|
||||||
|
--enable-libx265 \
|
||||||
|
--enable-lto=auto \
|
||||||
|
--enable-lv2 \
|
||||||
|
--enable-pic \
|
||||||
|
--enable-postproc \
|
||||||
|
--enable-pthreads \
|
||||||
|
--enable-shared \
|
||||||
|
--enable-vaapi \
|
||||||
|
--enable-version3 \
|
||||||
|
--optflags="-O3" \
|
||||||
|
&& make -j$(nproc) install \
|
||||||
|
&& apk del --no-cache .build-deps \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -r ffmpeg-${ffmpeg_ver} ffmpeg-${ffmpeg_ver}.tar.bz2 \
|
||||||
|
# we need Arial Black for the VideoAG logo \
|
||||||
|
&& wget -q https://www.freedesktop.org/software/fontconfig/webfonts/webfonts.tar.gz \
|
||||||
|
&& tar xfa webfonts.tar.gz \
|
||||||
|
&& cd msfonts \
|
||||||
|
&& for file in *.exe; do bsdtar xf "$file"; done \
|
||||||
|
&& install -Dm644 -t /usr/share/fonts/msfonts/ *.ttf *.TTF \
|
||||||
|
&& install -Dm644 -t /usr/share/licenses/msfonts/ Licen.TXT \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -r msfonts webfonts.tar.gz
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/render_video"]
|
277
LICENSE
Normal file
277
LICENSE
Normal file
|
@ -0,0 +1,277 @@
|
||||||
|
Eclipse Public License - v 2.0
|
||||||
|
|
||||||
|
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
|
||||||
|
PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
|
||||||
|
OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
|
||||||
|
|
||||||
|
1. DEFINITIONS
|
||||||
|
|
||||||
|
"Contribution" means:
|
||||||
|
|
||||||
|
a) in the case of the initial Contributor, the initial content
|
||||||
|
Distributed under this Agreement, and
|
||||||
|
|
||||||
|
b) in the case of each subsequent Contributor:
|
||||||
|
i) changes to the Program, and
|
||||||
|
ii) additions to the Program;
|
||||||
|
where such changes and/or additions to the Program originate from
|
||||||
|
and are Distributed by that particular Contributor. A Contribution
|
||||||
|
"originates" from a Contributor if it was added to the Program by
|
||||||
|
such Contributor itself or anyone acting on such Contributor's behalf.
|
||||||
|
Contributions do not include changes or additions to the Program that
|
||||||
|
are not Modified Works.
|
||||||
|
|
||||||
|
"Contributor" means any person or entity that Distributes the Program.
|
||||||
|
|
||||||
|
"Licensed Patents" mean patent claims licensable by a Contributor which
|
||||||
|
are necessarily infringed by the use or sale of its Contribution alone
|
||||||
|
or when combined with the Program.
|
||||||
|
|
||||||
|
"Program" means the Contributions Distributed in accordance with this
|
||||||
|
Agreement.
|
||||||
|
|
||||||
|
"Recipient" means anyone who receives the Program under this Agreement
|
||||||
|
or any Secondary License (as applicable), including Contributors.
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source Code or other
|
||||||
|
form, that is based on (or derived from) the Program and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship.
|
||||||
|
|
||||||
|
"Modified Works" shall mean any work in Source Code or other form that
|
||||||
|
results from an addition to, deletion from, or modification of the
|
||||||
|
contents of the Program, including, for purposes of clarity any new file
|
||||||
|
in Source Code form that contains any contents of the Program. Modified
|
||||||
|
Works shall not include works that contain only declarations,
|
||||||
|
interfaces, types, classes, structures, or files of the Program solely
|
||||||
|
in each case in order to link to, bind by name, or subclass the Program
|
||||||
|
or Modified Works thereof.
|
||||||
|
|
||||||
|
"Distribute" means the acts of a) distributing or b) making available
|
||||||
|
in any manner that enables the transfer of a copy.
|
||||||
|
|
||||||
|
"Source Code" means the form of a Program preferred for making
|
||||||
|
modifications, including but not limited to software source code,
|
||||||
|
documentation source, and configuration files.
|
||||||
|
|
||||||
|
"Secondary License" means either the GNU General Public License,
|
||||||
|
Version 2.0, or any later versions of that license, including any
|
||||||
|
exceptions or additional permissions as identified by the initial
|
||||||
|
Contributor.
|
||||||
|
|
||||||
|
2. GRANT OF RIGHTS
|
||||||
|
|
||||||
|
a) Subject to the terms of this Agreement, each Contributor hereby
|
||||||
|
grants Recipient a non-exclusive, worldwide, royalty-free copyright
|
||||||
|
license to reproduce, prepare Derivative Works of, publicly display,
|
||||||
|
publicly perform, Distribute and sublicense the Contribution of such
|
||||||
|
Contributor, if any, and such Derivative Works.
|
||||||
|
|
||||||
|
b) Subject to the terms of this Agreement, each Contributor hereby
|
||||||
|
grants Recipient a non-exclusive, worldwide, royalty-free patent
|
||||||
|
license under Licensed Patents to make, use, sell, offer to sell,
|
||||||
|
import and otherwise transfer the Contribution of such Contributor,
|
||||||
|
if any, in Source Code or other form. This patent license shall
|
||||||
|
apply to the combination of the Contribution and the Program if, at
|
||||||
|
the time the Contribution is added by the Contributor, such addition
|
||||||
|
of the Contribution causes such combination to be covered by the
|
||||||
|
Licensed Patents. The patent license shall not apply to any other
|
||||||
|
combinations which include the Contribution. No hardware per se is
|
||||||
|
licensed hereunder.
|
||||||
|
|
||||||
|
c) Recipient understands that although each Contributor grants the
|
||||||
|
licenses to its Contributions set forth herein, no assurances are
|
||||||
|
provided by any Contributor that the Program does not infringe the
|
||||||
|
patent or other intellectual property rights of any other entity.
|
||||||
|
Each Contributor disclaims any liability to Recipient for claims
|
||||||
|
brought by any other entity based on infringement of intellectual
|
||||||
|
property rights or otherwise. As a condition to exercising the
|
||||||
|
rights and licenses granted hereunder, each Recipient hereby
|
||||||
|
assumes sole responsibility to secure any other intellectual
|
||||||
|
property rights needed, if any. For example, if a third party
|
||||||
|
patent license is required to allow Recipient to Distribute the
|
||||||
|
Program, it is Recipient's responsibility to acquire that license
|
||||||
|
before distributing the Program.
|
||||||
|
|
||||||
|
d) Each Contributor represents that to its knowledge it has
|
||||||
|
sufficient copyright rights in its Contribution, if any, to grant
|
||||||
|
the copyright license set forth in this Agreement.
|
||||||
|
|
||||||
|
e) Notwithstanding the terms of any Secondary License, no
|
||||||
|
Contributor makes additional grants to any Recipient (other than
|
||||||
|
those set forth in this Agreement) as a result of such Recipient's
|
||||||
|
receipt of the Program under the terms of a Secondary License
|
||||||
|
(if permitted under the terms of Section 3).
|
||||||
|
|
||||||
|
3. REQUIREMENTS
|
||||||
|
|
||||||
|
3.1 If a Contributor Distributes the Program in any form, then:
|
||||||
|
|
||||||
|
a) the Program must also be made available as Source Code, in
|
||||||
|
accordance with section 3.2, and the Contributor must accompany
|
||||||
|
the Program with a statement that the Source Code for the Program
|
||||||
|
is available under this Agreement, and informs Recipients how to
|
||||||
|
obtain it in a reasonable manner on or through a medium customarily
|
||||||
|
used for software exchange; and
|
||||||
|
|
||||||
|
b) the Contributor may Distribute the Program under a license
|
||||||
|
different than this Agreement, provided that such license:
|
||||||
|
i) effectively disclaims on behalf of all other Contributors all
|
||||||
|
warranties and conditions, express and implied, including
|
||||||
|
warranties or conditions of title and non-infringement, and
|
||||||
|
implied warranties or conditions of merchantability and fitness
|
||||||
|
for a particular purpose;
|
||||||
|
|
||||||
|
ii) effectively excludes on behalf of all other Contributors all
|
||||||
|
liability for damages, including direct, indirect, special,
|
||||||
|
incidental and consequential damages, such as lost profits;
|
||||||
|
|
||||||
|
iii) does not attempt to limit or alter the recipients' rights
|
||||||
|
in the Source Code under section 3.2; and
|
||||||
|
|
||||||
|
iv) requires any subsequent distribution of the Program by any
|
||||||
|
party to be under a license that satisfies the requirements
|
||||||
|
of this section 3.
|
||||||
|
|
||||||
|
3.2 When the Program is Distributed as Source Code:
|
||||||
|
|
||||||
|
a) it must be made available under this Agreement, or if the
|
||||||
|
Program (i) is combined with other material in a separate file or
|
||||||
|
files made available under a Secondary License, and (ii) the initial
|
||||||
|
Contributor attached to the Source Code the notice described in
|
||||||
|
Exhibit A of this Agreement, then the Program may be made available
|
||||||
|
under the terms of such Secondary Licenses, and
|
||||||
|
|
||||||
|
b) a copy of this Agreement must be included with each copy of
|
||||||
|
the Program.
|
||||||
|
|
||||||
|
3.3 Contributors may not remove or alter any copyright, patent,
|
||||||
|
trademark, attribution notices, disclaimers of warranty, or limitations
|
||||||
|
of liability ("notices") contained within the Program from any copy of
|
||||||
|
the Program which they Distribute, provided that Contributors may add
|
||||||
|
their own appropriate notices.
|
||||||
|
|
||||||
|
4. COMMERCIAL DISTRIBUTION
|
||||||
|
|
||||||
|
Commercial distributors of software may accept certain responsibilities
|
||||||
|
with respect to end users, business partners and the like. While this
|
||||||
|
license is intended to facilitate the commercial use of the Program,
|
||||||
|
the Contributor who includes the Program in a commercial product
|
||||||
|
offering should do so in a manner which does not create potential
|
||||||
|
liability for other Contributors. Therefore, if a Contributor includes
|
||||||
|
the Program in a commercial product offering, such Contributor
|
||||||
|
("Commercial Contributor") hereby agrees to defend and indemnify every
|
||||||
|
other Contributor ("Indemnified Contributor") against any losses,
|
||||||
|
damages and costs (collectively "Losses") arising from claims, lawsuits
|
||||||
|
and other legal actions brought by a third party against the Indemnified
|
||||||
|
Contributor to the extent caused by the acts or omissions of such
|
||||||
|
Commercial Contributor in connection with its distribution of the Program
|
||||||
|
in a commercial product offering. The obligations in this section do not
|
||||||
|
apply to any claims or Losses relating to any actual or alleged
|
||||||
|
intellectual property infringement. In order to qualify, an Indemnified
|
||||||
|
Contributor must: a) promptly notify the Commercial Contributor in
|
||||||
|
writing of such claim, and b) allow the Commercial Contributor to control,
|
||||||
|
and cooperate with the Commercial Contributor in, the defense and any
|
||||||
|
related settlement negotiations. The Indemnified Contributor may
|
||||||
|
participate in any such claim at its own expense.
|
||||||
|
|
||||||
|
For example, a Contributor might include the Program in a commercial
|
||||||
|
product offering, Product X. That Contributor is then a Commercial
|
||||||
|
Contributor. If that Commercial Contributor then makes performance
|
||||||
|
claims, or offers warranties related to Product X, those performance
|
||||||
|
claims and warranties are such Commercial Contributor's responsibility
|
||||||
|
alone. Under this section, the Commercial Contributor would have to
|
||||||
|
defend claims against the other Contributors related to those performance
|
||||||
|
claims and warranties, and if a court requires any other Contributor to
|
||||||
|
pay any damages as a result, the Commercial Contributor must pay
|
||||||
|
those damages.
|
||||||
|
|
||||||
|
5. NO WARRANTY
|
||||||
|
|
||||||
|
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||||
|
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
|
||||||
|
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
|
||||||
|
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
|
||||||
|
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. Each Recipient is solely responsible for determining the
|
||||||
|
appropriateness of using and distributing the Program and assumes all
|
||||||
|
risks associated with its exercise of rights under this Agreement,
|
||||||
|
including but not limited to the risks and costs of program errors,
|
||||||
|
compliance with applicable laws, damage to or loss of data, programs
|
||||||
|
or equipment, and unavailability or interruption of operations.
|
||||||
|
|
||||||
|
6. DISCLAIMER OF LIABILITY
|
||||||
|
|
||||||
|
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
|
||||||
|
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
|
||||||
|
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
|
||||||
|
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||||
|
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
|
||||||
|
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
7. GENERAL
|
||||||
|
|
||||||
|
If any provision of this Agreement is invalid or unenforceable under
|
||||||
|
applicable law, it shall not affect the validity or enforceability of
|
||||||
|
the remainder of the terms of this Agreement, and without further
|
||||||
|
action by the parties hereto, such provision shall be reformed to the
|
||||||
|
minimum extent necessary to make such provision valid and enforceable.
|
||||||
|
|
||||||
|
If Recipient institutes patent litigation against any entity
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that the
|
||||||
|
Program itself (excluding combinations of the Program with other software
|
||||||
|
or hardware) infringes such Recipient's patent(s), then such Recipient's
|
||||||
|
rights granted under Section 2(b) shall terminate as of the date such
|
||||||
|
litigation is filed.
|
||||||
|
|
||||||
|
All Recipient's rights under this Agreement shall terminate if it
|
||||||
|
fails to comply with any of the material terms or conditions of this
|
||||||
|
Agreement and does not cure such failure in a reasonable period of
|
||||||
|
time after becoming aware of such noncompliance. If all Recipient's
|
||||||
|
rights under this Agreement terminate, Recipient agrees to cease use
|
||||||
|
and distribution of the Program as soon as reasonably practicable.
|
||||||
|
However, Recipient's obligations under this Agreement and any licenses
|
||||||
|
granted by Recipient relating to the Program shall continue and survive.
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute copies of this Agreement,
|
||||||
|
but in order to avoid inconsistency the Agreement is copyrighted and
|
||||||
|
may only be modified in the following manner. The Agreement Steward
|
||||||
|
reserves the right to publish new versions (including revisions) of
|
||||||
|
this Agreement from time to time. No one other than the Agreement
|
||||||
|
Steward has the right to modify this Agreement. The Eclipse Foundation
|
||||||
|
is the initial Agreement Steward. The Eclipse Foundation may assign the
|
||||||
|
responsibility to serve as the Agreement Steward to a suitable separate
|
||||||
|
entity. Each new version of the Agreement will be given a distinguishing
|
||||||
|
version number. The Program (including Contributions) may always be
|
||||||
|
Distributed subject to the version of the Agreement under which it was
|
||||||
|
received. In addition, after a new version of the Agreement is published,
|
||||||
|
Contributor may elect to Distribute the Program (including its
|
||||||
|
Contributions) under the new version.
|
||||||
|
|
||||||
|
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
|
||||||
|
receives no rights or licenses to the intellectual property of any
|
||||||
|
Contributor under this Agreement, whether expressly, by implication,
|
||||||
|
estoppel or otherwise. All rights in the Program not expressly granted
|
||||||
|
under this Agreement are reserved. Nothing in this Agreement is intended
|
||||||
|
to be enforceable by any entity that is not a Contributor or Recipient.
|
||||||
|
No third-party beneficiary rights are created under this Agreement.
|
||||||
|
|
||||||
|
Exhibit A - Form of Secondary Licenses Notice
|
||||||
|
|
||||||
|
"This Source Code may also be made available under the following
|
||||||
|
Secondary Licenses when the conditions for such availability set forth
|
||||||
|
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
|
||||||
|
version(s), and exceptions or additional permissions here}."
|
||||||
|
|
||||||
|
Simply including a copy of this Agreement, including this Exhibit A
|
||||||
|
is not sufficient to license the Source Code under Secondary Licenses.
|
||||||
|
|
||||||
|
If it is not possible or desirable to put the notice in a particular
|
||||||
|
file, then You may include the notice in a location (such as a LICENSE
|
||||||
|
file in a relevant directory) where a recipient would be likely to
|
||||||
|
look for such a notice.
|
||||||
|
|
||||||
|
You may add additional accurate notices of copyright ownership.
|
17
README.md
Normal file
17
README.md
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
**ACHTUNG!** This repository might be mirrored at different places, but the main repository is and remains at [msrd0.dev/msrd0/render_video](https://msrd0.dev/msrd0/render_video). Please redirect all issues and pull requests there.
|
||||||
|
|
||||||
|
# render_video
|
||||||
|
|
||||||
|
This "script" is an extremely fancy wrapper around ffmpeg to cut/render videos for the [VideoAG](https://video.fsmpi.rwth-aachen.de) of the [Fachschaft I/1 der RWTH Aachen University](https://fsmpi.rwth-aachen.de).
|
||||||
|
|
||||||
|
You can find a ready-to-use docker image at [`quay.io/msrd0/render_video`](https://quay.io/msrd0/render_video).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Extract a single audio channel from stereo recording.** We use that with one of our cameras that supports plugging a lavalier microphone (mono source) into one channel of the stereo recording, and using the camera microphone (mono source) for the other channel of the stereo recording.
|
||||||
|
- **Cut away before/after the lecture.** We don't hit the start record button the exact time that the lecture starts, and don't hit the stop button exactly when the lecture ends, so we need to cut away those unwanted bits.
|
||||||
|
- **Fast-forward through _Tafelwischpausen_.** Sometimes docents still use blackboards and need to wipe those, which can be fast-forwarded by this tool.
|
||||||
|
- **Overlay questions from the audience.** Sometimes people in the audience have questions, and those are usually poorly understood on the lavalier microphones. Therefore you can subtitle these using the other microphones in the room that don't make it into the final video and have those overlayed.
|
||||||
|
- **Add intro and outro.** We add intro and outro slides at the start/end at all lectures, which this tool can do for you.
|
||||||
|
- **Add our logo watermark.** We add a logo watermark in the bottom right corner of all videos, which this tool can do for you.
|
||||||
|
- **Rescale to lower resolutions.** We usually published videos at different resolutions, and this tool can rescale your video for all resolutions you want.
|
|
@ -1,8 +1,25 @@
|
||||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
<style type="text/css" id="current-color-scheme">
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
.ColorScheme-Text {
|
width="300" height="300"
|
||||||
color:#eff0f1;
|
version="1.0">
|
||||||
}
|
|
||||||
</style>
|
<defs>
|
||||||
<path d="m8 2v12l7-6zm-7 0v12l7-6z" class="ColorScheme-Text" fill="currentColor"/>
|
<path id="arrows" d="M160 60v180l105-90zM55 60v180l105-90z"/>
|
||||||
|
|
||||||
|
<clipPath id="background-cut">
|
||||||
|
<path d="M136.856 78.357a37.5 37.5 0 1 1-75 0 37.5 37.5 0 1 1 75 0"/>
|
||||||
|
<path d="M149.826-.002A150 150-26.33 1 1 0 150.17l150-.172z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g fill="#000" opacity=".4">
|
||||||
|
<path fill="#fff" d="M149.826-.002a150 150-26.33 1 1 .345 300 150 150-26.33 1 1-.345-300"/>
|
||||||
|
<path d="M136.856 78.357a37.5 37.5 0 1 1-75 0 37.5 37.5 0 1 1 75 0"/>
|
||||||
|
<path d="M149.826-.002A150 150-26.33 1 1 0 150.17l150-.172z"/>
|
||||||
|
|
||||||
|
<use href="#arrows"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<use href="#arrows" fill="#fff" clip-path="url(#background-cut)"/>
|
||||||
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 801 B |
225
assets/logo.svg
225
assets/logo.svg
|
@ -1,199 +1,36 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
<svg
|
<svg
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
width="300" height="300"
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
version="1.0">
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
<defs>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<linearGradient id="a">
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
<stop offset="0" stop-color="#fff"/>
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
<stop offset="1" stop-color="#7d7d7d"/>
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="299.99982"
|
|
||||||
height="300.00003"
|
|
||||||
id="svg2"
|
|
||||||
sodipodi:version="0.32"
|
|
||||||
inkscape:version="0.46"
|
|
||||||
sodipodi:docbase="C:\Eigene Dateien\Video AG"
|
|
||||||
sodipodi:docname="logo.svg"
|
|
||||||
inkscape:output_extension="org.inkscape.output.svg.inkscape"
|
|
||||||
version="1.0"
|
|
||||||
inkscape:export-filename="Q:\video AG\fs-pub-video\folien\logo-1024.png"
|
|
||||||
inkscape:export-xdpi="307.20001"
|
|
||||||
inkscape:export-ydpi="307.20001">
|
|
||||||
<defs
|
|
||||||
id="defs4">
|
|
||||||
<inkscape:perspective
|
|
||||||
sodipodi:type="inkscape:persp3d"
|
|
||||||
inkscape:vp_x="0 : 526.18109 : 1"
|
|
||||||
inkscape:vp_y="0 : 1000 : 0"
|
|
||||||
inkscape:vp_z="744.09448 : 526.18109 : 1"
|
|
||||||
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
|
|
||||||
id="perspective21" />
|
|
||||||
<linearGradient
|
|
||||||
id="linearGradient2041">
|
|
||||||
<stop
|
|
||||||
style="stop-color:#ffffff;stop-opacity:1.0000000;"
|
|
||||||
offset="0.00000000"
|
|
||||||
id="stop2043" />
|
|
||||||
<stop
|
|
||||||
style="stop-color:#7d7d7d;stop-opacity:1.0000000;"
|
|
||||||
offset="1.0000000"
|
|
||||||
id="stop2045" />
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient
|
<linearGradient xlink:href="#a" id="b" x1="194.176" x2="250.039" y1="139.593" y2="139.593" gradientTransform="translate(317 -57)" gradientUnits="userSpaceOnUse"/>
|
||||||
inkscape:collect="always"
|
|
||||||
xlink:href="#linearGradient2041"
|
|
||||||
id="linearGradient2047"
|
|
||||||
x1="194.17578"
|
|
||||||
y1="139.59265"
|
|
||||||
x2="250.03906"
|
|
||||||
y2="139.59265"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
gradientTransform="translate(317,-57)" />
|
|
||||||
</defs>
|
</defs>
|
||||||
<sodipodi:namedview
|
|
||||||
id="base"
|
<!-- this creates the small circle inside the cutout. the first path seems to do nothing. -->
|
||||||
pagecolor="#ffffff"
|
<path fill="#fff" d="M149.826-.002a150 150-26.33 1 1 .345 300 150 150-26.33 1 1-.345-300"/>
|
||||||
bordercolor="#666666"
|
<path d="M136.856 78.357a37.5 37.5 0 1 1-75 0 37.5 37.5 0 1 1 75 0"/>
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="0"
|
<!-- this is the big 270-degree circle -->
|
||||||
inkscape:pageshadow="2"
|
<path d="M149.826-.002A150 150-26.33 1 1 0 150.17l150-.172z"/>
|
||||||
inkscape:zoom="1.4889823"
|
|
||||||
inkscape:cx="71.681092"
|
<g font-family="Arial Black" style="line-height:125%" transform="translate(-439 .638)">
|
||||||
inkscape:cy="217.8489"
|
|
||||||
inkscape:document-units="px"
|
<text font-size="72">
|
||||||
inkscape:current-layer="layer1"
|
<!-- this text is the "V" with linear gradient inside the small circle. -->
|
||||||
inkscape:window-width="1400"
|
<tspan x="511" y="108.362" fill="url(#b)">V</tspan>
|
||||||
inkscape:window-height="988"
|
<!-- this is the "V" to the left of the small circle -->
|
||||||
inkscape:window-x="-8"
|
<tspan x="471" y="108.362" fill="#000" fill-opacity=".251">V</tspan>
|
||||||
inkscape:window-y="-8"
|
</text>
|
||||||
showgrid="false"
|
|
||||||
inkscape:snap-bbox="false"
|
<!-- this is the "ideo" text in the upper line -->
|
||||||
inkscape:snap-nodes="true"
|
<text x="598" y="105.362" font-size="36" fill="#fff">ideo</text>
|
||||||
inkscape:object-paths="false"
|
|
||||||
inkscape:object-nodes="true"
|
<!-- this is the "AG" text in the lower line -->
|
||||||
objecttolerance="10"
|
<text x="511" y="243.362" font-size="100" fill="#fff">AG</text>
|
||||||
gridtolerance="10000"
|
|
||||||
guidetolerance="10000"
|
|
||||||
showborder="true"
|
|
||||||
inkscape:showpageshadow="true"
|
|
||||||
borderlayer="false" />
|
|
||||||
<metadata
|
|
||||||
id="metadata7">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
inkscape:label="Ebene 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(-438.99979,0.6379836)">
|
|
||||||
<path
|
|
||||||
sodipodi:type="arc"
|
|
||||||
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="path2393"
|
|
||||||
sodipodi:cx="454"
|
|
||||||
sodipodi:cy="112.36218"
|
|
||||||
sodipodi:rx="87"
|
|
||||||
sodipodi:ry="56"
|
|
||||||
d="M 541,112.36218 A 87,56 0 1 1 367,112.36218 A 87,56 0 1 1 541,112.36218 z"
|
|
||||||
transform="matrix(-1.977323e-3,-1.724137,2.678569,-3.071905e-3,288.9275,932.4654)"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000" />
|
|
||||||
<path
|
|
||||||
sodipodi:type="arc"
|
|
||||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="path1306"
|
|
||||||
sodipodi:cx="281.42856"
|
|
||||||
sodipodi:cy="270.93362"
|
|
||||||
sodipodi:rx="38.57143"
|
|
||||||
sodipodi:ry="44.285713"
|
|
||||||
d="M 319.99999,270.93362 A 38.57143,44.285713 0 1 1 242.85713,270.93362 A 38.57143,44.285713 0 1 1 319.99999,270.93362 z"
|
|
||||||
transform="matrix(0.972222,0,0,0.846774,264.7456,-151.7001)"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:72px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:url(#linearGradient2047);fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Arial Black"
|
|
||||||
x="511"
|
|
||||||
y="108.36218"
|
|
||||||
id="text1308"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan1312"
|
|
||||||
x="511"
|
|
||||||
y="108.36218"
|
|
||||||
style="fill:url(#linearGradient2047);fill-opacity:1">V</tspan></text>
|
|
||||||
<path
|
|
||||||
sodipodi:type="arc"
|
|
||||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke-width:1;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
|
|
||||||
id="path3517"
|
|
||||||
sodipodi:cx="454"
|
|
||||||
sodipodi:cy="112.36218"
|
|
||||||
sodipodi:rx="87"
|
|
||||||
sodipodi:ry="56"
|
|
||||||
d="M 541,112.36218 A 87,56 0 1 1 454,56.362183 L 454,112.36218 z"
|
|
||||||
transform="matrix(-1.977323e-3,-1.724137,2.678569,-3.071905e-3,288.9275,932.4654)"
|
|
||||||
sodipodi:start="0"
|
|
||||||
sodipodi:end="4.712389"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000" />
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:28px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Arial Black"
|
|
||||||
x="598"
|
|
||||||
y="105.36218"
|
|
||||||
id="text2055"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan2785"
|
|
||||||
x="598"
|
|
||||||
y="105.36218"
|
|
||||||
style="font-size:36px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;font-family:Arial Black">ideo</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:100px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Arial Black"
|
|
||||||
x="511"
|
|
||||||
y="243.36218"
|
|
||||||
id="text2051"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan2053"
|
|
||||||
x="511"
|
|
||||||
y="243.36218"
|
|
||||||
style="font-size:100px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;font-family:Arial Black">AG</tspan></text>
|
|
||||||
<text
|
|
||||||
xml:space="preserve"
|
|
||||||
style="font-size:72px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:0.25098039;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:Arial Black"
|
|
||||||
x="471"
|
|
||||||
y="108.36218"
|
|
||||||
id="text4979"
|
|
||||||
sodipodi:linespacing="125%"
|
|
||||||
inkscape:export-filename="C:\Eigene Dateien\Video AG\logo.png"
|
|
||||||
inkscape:export-xdpi="90.000000"
|
|
||||||
inkscape:export-ydpi="90.000000"><tspan
|
|
||||||
sodipodi:role="line"
|
|
||||||
id="tspan4981"
|
|
||||||
x="471"
|
|
||||||
y="108.36218">V</tspan></text>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 1.5 KiB |
54
src/cli.rs
Normal file
54
src/cli.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
//! This module contains helper functions for implementing CLI/TUI.
|
||||||
|
|
||||||
|
use crate::time::{parse_time, Time};
|
||||||
|
use console::style;
|
||||||
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
io::{self, BufRead as _, Write as _}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn ask(question: impl Display) -> String {
|
||||||
|
let mut stdout = io::stdout().lock();
|
||||||
|
let mut stdin = io::stdin().lock();
|
||||||
|
|
||||||
|
write!(
|
||||||
|
stdout,
|
||||||
|
"{} {} ",
|
||||||
|
style(question).bold().magenta(),
|
||||||
|
style(">").cyan()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stdout.flush().unwrap();
|
||||||
|
let mut line = String::new();
|
||||||
|
stdin.read_line(&mut line).unwrap();
|
||||||
|
line.trim().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ask_time(question: impl Display + Copy) -> Time {
|
||||||
|
let mut stdout = io::stdout().lock();
|
||||||
|
let mut stdin = io::stdin().lock();
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
write!(
|
||||||
|
stdout,
|
||||||
|
"{} {} ",
|
||||||
|
style(question).bold().magenta(),
|
||||||
|
style(">").cyan()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stdout.flush().unwrap();
|
||||||
|
stdin.read_line(&mut line).unwrap();
|
||||||
|
let line = line.trim();
|
||||||
|
match parse_time(line) {
|
||||||
|
Ok(time) => return time,
|
||||||
|
Err(err) => writeln!(
|
||||||
|
stdout,
|
||||||
|
"{} {line:?}: {err}",
|
||||||
|
style("Invalid Input").bold().red()
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
173
src/iotro.rs
173
src/iotro.rs
|
@ -1,23 +1,156 @@
|
||||||
//! A module for writing intros and outros
|
//! A module for writing intros and outros
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
time::{format_date_long, Date},
|
project::{ProjectLecture, Resolution},
|
||||||
Resolution
|
time::Date
|
||||||
|
};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use std::{
|
||||||
|
fmt::{self, Debug, Display, Formatter},
|
||||||
|
str::FromStr
|
||||||
};
|
};
|
||||||
use svgwriter::{
|
use svgwriter::{
|
||||||
tags::{Group, Rect, TagWithPresentationAttributes, Text},
|
tags::{Group, Rect, TagWithPresentationAttributes as _, Text},
|
||||||
Graphic
|
Graphic
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Language<'a> {
|
||||||
|
pub(crate) lang: &'a str,
|
||||||
|
pub(crate) format_date_long: fn(Date) -> String,
|
||||||
|
// intro
|
||||||
|
lecture_from: &'a str,
|
||||||
|
pub(crate) video_created_by_us: &'a str,
|
||||||
|
// outro
|
||||||
|
video_created_by: &'a str,
|
||||||
|
our_website: &'a str,
|
||||||
|
download_videos: &'a str,
|
||||||
|
questions_feedback: &'a str,
|
||||||
|
// metadata
|
||||||
|
pub(crate) from: &'a str,
|
||||||
|
// questions
|
||||||
|
pub(crate) question: &'a str
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const GERMAN: Language<'static> = Language {
|
||||||
|
lang: "de",
|
||||||
|
|
||||||
|
// Format a date in DD. MMMM YYYY format.
|
||||||
|
format_date_long: |d: Date| {
|
||||||
|
let month = match d.month {
|
||||||
|
1 => "Januar",
|
||||||
|
2 => "Februar",
|
||||||
|
3 => "März",
|
||||||
|
4 => "April",
|
||||||
|
5 => "Mai",
|
||||||
|
6 => "Juni",
|
||||||
|
7 => "Juli",
|
||||||
|
8 => "August",
|
||||||
|
9 => "September",
|
||||||
|
10 => "Oktober",
|
||||||
|
11 => "November",
|
||||||
|
12 => "Dezember",
|
||||||
|
_ => unreachable!()
|
||||||
|
};
|
||||||
|
format!("{:02}. {month} {:04}", d.day, d.year)
|
||||||
|
},
|
||||||
|
|
||||||
|
lecture_from: "Vorlesung vom",
|
||||||
|
video_created_by_us: "Video erstellt von der Video AG, Fachschaft I/1",
|
||||||
|
|
||||||
|
video_created_by: "Video erstellt von der",
|
||||||
|
our_website: "Website der Fachschaft",
|
||||||
|
download_videos: "Videos herunterladen",
|
||||||
|
questions_feedback: "Fragen, Vorschläge und Feedback",
|
||||||
|
|
||||||
|
from: "vom",
|
||||||
|
|
||||||
|
question: "Frage"
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const BRITISH: Language<'static> = Language {
|
||||||
|
lang: "uk",
|
||||||
|
|
||||||
|
// Format a date in DDth MMMM YYYY format.
|
||||||
|
format_date_long: |d: Date| {
|
||||||
|
let month = match d.month {
|
||||||
|
1 => "January",
|
||||||
|
2 => "February",
|
||||||
|
3 => "March",
|
||||||
|
4 => "April",
|
||||||
|
5 => "May",
|
||||||
|
6 => "June",
|
||||||
|
7 => "July",
|
||||||
|
8 => "August",
|
||||||
|
9 => "September",
|
||||||
|
10 => "October",
|
||||||
|
11 => "November",
|
||||||
|
12 => "December",
|
||||||
|
_ => unreachable!()
|
||||||
|
};
|
||||||
|
let th = match d.day {
|
||||||
|
1 | 21 | 31 => "st",
|
||||||
|
2 | 22 => "nd",
|
||||||
|
3 | 23 => "rd",
|
||||||
|
_ => "th"
|
||||||
|
};
|
||||||
|
format!("{}{th} {month} {:04}", d.day, d.year)
|
||||||
|
},
|
||||||
|
|
||||||
|
lecture_from: "Lecture from",
|
||||||
|
video_created_by_us: "Video created by the Video AG, Fachschaft I/1",
|
||||||
|
|
||||||
|
video_created_by: "Video created by the",
|
||||||
|
our_website: "The Fachschaft's website",
|
||||||
|
download_videos: "Download videos",
|
||||||
|
questions_feedback: "Questions, Suggestions and Feedback",
|
||||||
|
|
||||||
|
from: "from",
|
||||||
|
|
||||||
|
question: "Question"
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Default for Language<'static> {
|
||||||
|
fn default() -> Self {
|
||||||
|
GERMAN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Language<'static> {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"de" => Ok(GERMAN),
|
||||||
|
"en" | "uk" => Ok(BRITISH),
|
||||||
|
lang => Err(anyhow!("Unknown language {lang:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Language<'_> {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for Language<'_> {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Language")
|
||||||
|
.field("lang", &self.lang)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(u16)]
|
#[repr(u16)]
|
||||||
enum FontSize {
|
pub(crate) enum FontSize {
|
||||||
Huge = 72,
|
Huge = 72,
|
||||||
Large = 56,
|
Large = 56,
|
||||||
Big = 44
|
Big = 44
|
||||||
}
|
}
|
||||||
|
|
||||||
#[repr(u16)]
|
#[repr(u16)]
|
||||||
enum FontWeight {
|
pub(crate) enum FontWeight {
|
||||||
Normal = 400,
|
Normal = 400,
|
||||||
SemiBold = 500,
|
SemiBold = 500,
|
||||||
Bold = 700
|
Bold = 700
|
||||||
|
@ -74,41 +207,41 @@ impl Iotro {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn intro(res: Resolution, date: Date) -> Graphic {
|
pub(crate) fn intro(res: Resolution, lecture: &ProjectLecture) -> Graphic {
|
||||||
use self::{FontSize::*, FontWeight::*};
|
use self::{FontSize::*, FontWeight::*};
|
||||||
|
|
||||||
|
let lang = &lecture.lang;
|
||||||
let mut intro = Iotro::new(res);
|
let mut intro = Iotro::new(res);
|
||||||
intro.add_text(Huge, Bold, 110, "Mathematische Logik II");
|
intro.add_text(Huge, Bold, 110, &lecture.label);
|
||||||
intro.add_text(Huge, SemiBold, 250, "Prof. E. Grädel");
|
intro.add_text(Huge, SemiBold, 250, &lecture.docent);
|
||||||
intro.add_text(
|
intro.add_text(
|
||||||
Huge,
|
Huge,
|
||||||
SemiBold,
|
SemiBold,
|
||||||
460,
|
460,
|
||||||
format!("Vorlesung vom {}", format_date_long(date))
|
format!(
|
||||||
);
|
"{} {}",
|
||||||
intro.add_text(
|
lang.lecture_from,
|
||||||
Big,
|
(lang.format_date_long)(lecture.date)
|
||||||
Normal,
|
)
|
||||||
870,
|
|
||||||
"Video erstellt von der Video AG, Fachschaft I/1"
|
|
||||||
);
|
);
|
||||||
|
intro.add_text(Big, Normal, 870, lang.video_created_by_us);
|
||||||
intro.add_text(Big, Normal, 930, "https://video.fsmpi.rwth-aachen.de");
|
intro.add_text(Big, Normal, 930, "https://video.fsmpi.rwth-aachen.de");
|
||||||
intro.add_text(Big, Normal, 990, "video@fsmpi.rwth-aachen.de");
|
intro.add_text(Big, Normal, 990, "video@fsmpi.rwth-aachen.de");
|
||||||
|
|
||||||
intro.finish()
|
intro.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn outro(res: Resolution) -> Graphic {
|
pub(crate) fn outro(lang: &Language<'_>, res: Resolution) -> Graphic {
|
||||||
use self::{FontSize::*, FontWeight::*};
|
use self::{FontSize::*, FontWeight::*};
|
||||||
|
|
||||||
let mut outro = Iotro::new(res);
|
let mut outro = Iotro::new(res);
|
||||||
outro.add_text(Large, SemiBold, 50, "Video erstellt von der");
|
outro.add_text(Large, SemiBold, 50, lang.video_created_by);
|
||||||
outro.add_text(Huge, Bold, 210, "Video AG, Fachschaft I/1");
|
outro.add_text(Huge, Bold, 210, "Video AG, Fachschaft I/1");
|
||||||
outro.add_text(Large, Normal, 360, "Website der Fachschaft:");
|
outro.add_text(Large, Normal, 360, format!("{}:", lang.our_website));
|
||||||
outro.add_text(Large, Normal, 430, "https://www.fsmpi.rwth-aachen.de");
|
outro.add_text(Large, Normal, 430, "https://www.fsmpi.rwth-aachen.de");
|
||||||
outro.add_text(Large, Normal, 570, "Videos herunterladen:");
|
outro.add_text(Large, Normal, 570, format!("{}:", lang.download_videos));
|
||||||
outro.add_text(Large, Normal, 640, "https://video.fsmpi.rwth-aachen.de");
|
outro.add_text(Large, Normal, 640, "https://video.fsmpi.rwth-aachen.de");
|
||||||
outro.add_text(Large, Normal, 780, "Fragen, Vorschläge und Feedback:");
|
outro.add_text(Large, Normal, 780, format!("{}:", lang.questions_feedback));
|
||||||
outro.add_text(Large, Normal, 850, "video@fsmpi.rwth-aachen.de");
|
outro.add_text(Large, Normal, 850, "video@fsmpi.rwth-aachen.de");
|
||||||
|
|
||||||
outro.finish()
|
outro.finish()
|
||||||
|
|
17
src/lib.rs
Normal file
17
src/lib.rs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#![allow(clippy::manual_range_contains)]
|
||||||
|
#![warn(clippy::unreadable_literal, rust_2018_idioms)]
|
||||||
|
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||||
|
|
||||||
|
pub mod cli;
|
||||||
|
pub mod iotro;
|
||||||
|
pub mod preset;
|
||||||
|
pub mod project;
|
||||||
|
pub mod question;
|
||||||
|
pub mod render;
|
||||||
|
pub mod time;
|
||||||
|
|
||||||
|
#[cfg(feature = "mem_limit")]
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
#[cfg(feature = "mem_limit")]
|
||||||
|
pub static MEM_LIMIT: RwLock<String> = RwLock::new(String::new());
|
321
src/main.rs
321
src/main.rs
|
@ -1,162 +1,62 @@
|
||||||
#![allow(clippy::manual_range_contains)]
|
#![allow(clippy::manual_range_contains)]
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(clippy::unreadable_literal, rust_2018_idioms)]
|
||||||
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||||
|
|
||||||
mod iotro;
|
|
||||||
mod render;
|
|
||||||
mod time;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
render::Renderer,
|
|
||||||
time::{parse_date, parse_time, Date, Time}
|
|
||||||
};
|
|
||||||
use camino::Utf8PathBuf as PathBuf;
|
use camino::Utf8PathBuf as PathBuf;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rational::Rational;
|
use console::style;
|
||||||
use serde::{Deserialize, Serialize};
|
use render_video::{
|
||||||
use serde_with::{serde_as, DisplayFromStr};
|
cli::{ask, ask_time},
|
||||||
use std::{
|
preset::Preset,
|
||||||
collections::BTreeSet,
|
project::{Project, ProjectLecture, ProjectSource, Resolution},
|
||||||
fmt::Display,
|
render::Renderer,
|
||||||
fs,
|
time::parse_date
|
||||||
io::{self, BufRead as _, Write},
|
|
||||||
sync::RwLock
|
|
||||||
};
|
};
|
||||||
|
use std::fs;
|
||||||
static MEM_LIMIT: RwLock<String> = RwLock::new(String::new());
|
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
struct Args {
|
struct Args {
|
||||||
|
/// The root directory of the project. It should contain the raw video file(s).
|
||||||
#[clap(short = 'C', long, default_value = ".")]
|
#[clap(short = 'C', long, default_value = ".")]
|
||||||
directory: PathBuf,
|
directory: PathBuf,
|
||||||
|
|
||||||
#[clap(short = 'c', long, default_value = "23ws-malo")]
|
/// The preset of the lecture. Can be a toml file or a known course slug.
|
||||||
course: String,
|
#[clap(short, long)]
|
||||||
|
preset: String,
|
||||||
|
|
||||||
#[clap(short, long, default_value = "8G")]
|
#[cfg(feature = "mem_limit")]
|
||||||
mem_limit: String
|
/// The memory limit for external tools like ffmpeg.
|
||||||
}
|
#[clap(short, long, default_value = "12G")]
|
||||||
|
mem_limit: String,
|
||||||
|
|
||||||
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
|
/// Transcode the final video clip down to the minimum resolution specified. If not
|
||||||
#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
/// specified, the default value from the preset is used.
|
||||||
enum Resolution {
|
#[clap(short, long)]
|
||||||
/// 640x360
|
transcode: Option<Resolution>,
|
||||||
nHD,
|
|
||||||
/// 1280x720
|
|
||||||
HD,
|
|
||||||
/// 1920x1080
|
|
||||||
FullHD,
|
|
||||||
/// 2560x1440
|
|
||||||
WQHD,
|
|
||||||
/// 3840x2160
|
|
||||||
UHD
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Resolution {
|
/// Transcode starts at this resolution, or the source resolution, whichever is lower.
|
||||||
fn width(self) -> usize {
|
/// If not specified, the default value from the preset is used.
|
||||||
match self {
|
#[clap(short = 'T', long)]
|
||||||
Self::nHD => 640,
|
transcode_start: Option<Resolution>,
|
||||||
Self::HD => 1280,
|
|
||||||
Self::FullHD => 1920,
|
|
||||||
Self::WQHD => 2560,
|
|
||||||
Self::UHD => 3840
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn height(self) -> usize {
|
/// Treat the audio as stereo. By default, only one channel from the input stereo will
|
||||||
match self {
|
/// be used, assuming either the other channel is backup or the same as the used.
|
||||||
Self::nHD => 360,
|
#[clap(short, long, default_value = "false")]
|
||||||
Self::HD => 720,
|
stereo: bool
|
||||||
Self::FullHD => 1080,
|
|
||||||
Self::WQHD => 1440,
|
|
||||||
Self::UHD => 2160
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
|
||||||
struct Project {
|
|
||||||
lecture: ProjectLecture,
|
|
||||||
source: ProjectSource,
|
|
||||||
progress: ProjectProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Deserialize, Serialize)]
|
|
||||||
struct ProjectLecture {
|
|
||||||
course: String,
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
|
||||||
date: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Deserialize, Serialize)]
|
|
||||||
struct ProjectSource {
|
|
||||||
files: Vec<String>,
|
|
||||||
|
|
||||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
|
||||||
start: Option<Time>,
|
|
||||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
|
||||||
end: Option<Time>,
|
|
||||||
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
|
|
||||||
fast: Vec<(Time, Time)>,
|
|
||||||
|
|
||||||
metadata: Option<ProjectSourceMetadata>
|
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Deserialize, Serialize)]
|
|
||||||
struct ProjectSourceMetadata {
|
|
||||||
/// The duration of the source video.
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
|
||||||
source_duration: Time,
|
|
||||||
/// The FPS of the source video.
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
|
||||||
source_fps: Rational,
|
|
||||||
/// The time base of the source video.
|
|
||||||
#[serde_as(as = "DisplayFromStr")]
|
|
||||||
source_tbn: Rational,
|
|
||||||
/// The resolution of the source video.
|
|
||||||
source_res: Resolution,
|
|
||||||
/// The sample rate of the source audio.
|
|
||||||
source_sample_rate: u32
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Deserialize, Serialize)]
|
|
||||||
struct ProjectProgress {
|
|
||||||
preprocessed: bool,
|
|
||||||
asked_start_end: bool,
|
|
||||||
asked_fast: bool,
|
|
||||||
rendered: bool,
|
|
||||||
transcoded: BTreeSet<Resolution>
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ask_time(question: impl Display) -> Time {
|
|
||||||
let mut stdout = io::stdout().lock();
|
|
||||||
let mut stdin = io::stdin().lock();
|
|
||||||
|
|
||||||
writeln!(stdout, "{question}").unwrap();
|
|
||||||
let mut line = String::new();
|
|
||||||
loop {
|
|
||||||
line.clear();
|
|
||||||
write!(stdout, "> ").unwrap();
|
|
||||||
stdout.flush().unwrap();
|
|
||||||
stdin.read_line(&mut line).unwrap();
|
|
||||||
let line = line.trim();
|
|
||||||
match parse_time(line) {
|
|
||||||
Ok(time) => return time,
|
|
||||||
Err(err) => writeln!(stdout, "Invalid Input {line:?}: {err}").unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
*(MEM_LIMIT.write().unwrap()) = args.mem_limit;
|
|
||||||
|
#[cfg(feature = "mem_limit")]
|
||||||
|
{
|
||||||
|
*(render_video::MEM_LIMIT.write().unwrap()) = args.mem_limit;
|
||||||
|
}
|
||||||
|
|
||||||
// process arguments
|
// process arguments
|
||||||
let directory = args.directory.canonicalize_utf8().unwrap();
|
let directory = args.directory.canonicalize_utf8().unwrap();
|
||||||
let course = args.course;
|
let preset = Preset::find(&args.preset).unwrap();
|
||||||
|
let course = preset.course;
|
||||||
|
|
||||||
// let's see if we need to initialise the project
|
// let's see if we need to initialise the project
|
||||||
let project_path = directory.join("project.toml");
|
let project_path = directory.join("project.toml");
|
||||||
|
@ -172,23 +72,48 @@ fn main() {
|
||||||
let entry = entry.unwrap();
|
let entry = entry.unwrap();
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let lower = name.to_ascii_lowercase();
|
let lower = name.to_ascii_lowercase();
|
||||||
if (lower.ends_with(".mp4") || lower.ends_with(".mts"))
|
if (lower.ends_with(".mp4")
|
||||||
&& entry.file_type().unwrap().is_file()
|
|| lower.ends_with(".mts")
|
||||||
|
|| lower.ends_with(".mkv")
|
||||||
|
|| lower.ends_with(".mov"))
|
||||||
|
&& !entry.file_type().unwrap().is_dir()
|
||||||
{
|
{
|
||||||
files.push(String::from(name));
|
files.push(String::from(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.sort_unstable();
|
files.sort_unstable();
|
||||||
assert!(!files.is_empty());
|
assert!(!files.is_empty());
|
||||||
println!("I found the following source files: {files:?}");
|
|
||||||
|
print!("I found the following source files:");
|
||||||
|
for f in &files {
|
||||||
|
print!(" {}", style(f).bold().yellow());
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{} Which source files would you like to use? (specify multiple files separated by whitespace)",
|
||||||
|
style("?").bold().yellow()
|
||||||
|
);
|
||||||
|
files = ask("files")
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
assert!(!files.is_empty());
|
||||||
|
|
||||||
let project = Project {
|
let project = Project {
|
||||||
lecture: ProjectLecture { course, date },
|
lecture: ProjectLecture {
|
||||||
|
course,
|
||||||
|
label: preset.label,
|
||||||
|
docent: preset.docent,
|
||||||
|
date,
|
||||||
|
lang: preset.lang
|
||||||
|
},
|
||||||
source: ProjectSource {
|
source: ProjectSource {
|
||||||
files,
|
files,
|
||||||
|
stereo: args.stereo,
|
||||||
start: None,
|
start: None,
|
||||||
end: None,
|
end: None,
|
||||||
fast: Vec::new(),
|
fast: Vec::new(),
|
||||||
|
questions: Vec::new(),
|
||||||
metadata: None
|
metadata: None
|
||||||
},
|
},
|
||||||
progress: Default::default()
|
progress: Default::default()
|
||||||
|
@ -197,54 +122,128 @@ fn main() {
|
||||||
project
|
project
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
let mut renderer = Renderer::new(&directory, &project).unwrap();
|
||||||
|
let recording = renderer.recording_mkv();
|
||||||
let renderer = Renderer::new(&directory, &project).unwrap();
|
|
||||||
let recording = renderer.recording_mp4();
|
|
||||||
|
|
||||||
// preprocess the video
|
// preprocess the video
|
||||||
if !project.progress.preprocessed {
|
if !project.progress.preprocessed {
|
||||||
renderer.preprocess(&mut project).unwrap();
|
renderer.preprocess(&mut project).unwrap();
|
||||||
project.progress.preprocessed = true;
|
project.progress.preprocessed = true;
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
|
||||||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} Preprocessed video: {}",
|
||||||
|
style("==>").bold().cyan(),
|
||||||
|
style(recording).bold().yellow()
|
||||||
|
);
|
||||||
|
|
||||||
// ask the user about start and end times
|
// ask the user about start and end times
|
||||||
if !project.progress.asked_start_end {
|
if !project.progress.asked_start_end {
|
||||||
project.source.start = Some(ask_time(format_args!(
|
println!(
|
||||||
"Please take a look at the file {recording} and tell me the first second you want included"
|
"{} What is the first/last second you want included?",
|
||||||
)));
|
style("?").bold().yellow()
|
||||||
project.source.end = Some(ask_time(format_args!(
|
);
|
||||||
"Please take a look at the file {recording} and tell me the last second you want included"
|
project.source.start = Some(ask_time("first"));
|
||||||
)));
|
project.source.end = Some(ask_time("last "));
|
||||||
project.progress.asked_start_end = true;
|
project.progress.asked_start_end = true;
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
|
||||||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask the user about fast forward times
|
// ask the user about fast forward times
|
||||||
if !project.progress.asked_fast {
|
if !project.progress.asked_fast {
|
||||||
|
println!(
|
||||||
|
"{} Which sections of the video do you want fast-forwarded? (0 to finish)",
|
||||||
|
style("?").bold().yellow()
|
||||||
|
);
|
||||||
loop {
|
loop {
|
||||||
let start = ask_time(format_args!(
|
let start = ask_time("from");
|
||||||
"Please take a look at the file {recording} and tell me the first second you want fast-forwarded. You may reply with `0` if there are no more fast-forward sections"
|
|
||||||
));
|
|
||||||
if start.seconds == 0 && start.micros == 0 {
|
if start.seconds == 0 && start.micros == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let end = ask_time(format_args!(
|
let end = ask_time("to ");
|
||||||
"Please tell me the last second you want fast-forwarded"
|
|
||||||
));
|
|
||||||
project.source.fast.push((start, end));
|
project.source.fast.push((start, end));
|
||||||
}
|
}
|
||||||
project.progress.asked_fast = true;
|
project.progress.asked_fast = true;
|
||||||
|
|
||||||
println!("{}", toml::to_string(&project).unwrap());
|
|
||||||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let video = renderer.render(&mut project).unwrap();
|
// ask the user about questions from the audience that should be subtitled
|
||||||
println!("\x1B[1m ==> DONE :)\x1B[0m Video: {video}");
|
if !project.progress.asked_questions {
|
||||||
|
println!(
|
||||||
|
"{} In which sections of the video were questions asked you want subtitles for? (0 to finish)",
|
||||||
|
style("?").bold().yellow()
|
||||||
|
);
|
||||||
|
loop {
|
||||||
|
let start = ask_time("from");
|
||||||
|
if start.seconds == 0 && start.micros == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end = ask_time("to ");
|
||||||
|
let text = ask("text");
|
||||||
|
project.source.questions.push((start, end, text));
|
||||||
|
}
|
||||||
|
project.progress.asked_questions = true;
|
||||||
|
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// render the assets
|
||||||
|
if !project.progress.rendered_assets {
|
||||||
|
renderer.render_assets(&project).unwrap();
|
||||||
|
project.progress.rendered_assets = true;
|
||||||
|
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// render the video
|
||||||
|
let mut videos = Vec::new();
|
||||||
|
videos.push(if project.progress.rendered {
|
||||||
|
renderer.video_file_output()
|
||||||
|
} else {
|
||||||
|
let video = renderer.render(&mut project).unwrap();
|
||||||
|
project.progress.rendered = true;
|
||||||
|
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
|
||||||
|
video
|
||||||
|
});
|
||||||
|
|
||||||
|
// rescale the video
|
||||||
|
if let Some(lowest_res) = args.transcode.or(preset.transcode) {
|
||||||
|
for res in Resolution::STANDARD_RESOLUTIONS.into_iter().rev() {
|
||||||
|
if res > project.source.metadata.as_ref().unwrap().source_res
|
||||||
|
|| res > args.transcode_start.unwrap_or(preset.transcode_start)
|
||||||
|
|| res < lowest_res
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !project.progress.transcoded.contains(&res) {
|
||||||
|
videos.push(renderer.rescale(&project.lecture, res).unwrap());
|
||||||
|
project.progress.transcoded.insert(res);
|
||||||
|
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("==>").bold().cyan(),
|
||||||
|
style("DONE :)").bold()
|
||||||
|
);
|
||||||
|
println!(" Videos:");
|
||||||
|
for v in &videos {
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("->").bold().cyan(),
|
||||||
|
style(v).bold().yellow()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
79
src/preset.rs
Normal file
79
src/preset.rs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
//! This struct defines presets.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
iotro::{Language, BRITISH, GERMAN},
|
||||||
|
project::Resolution
|
||||||
|
};
|
||||||
|
use anyhow::bail;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
pub struct Preset {
|
||||||
|
// options for the intro slide
|
||||||
|
pub course: String,
|
||||||
|
pub label: String,
|
||||||
|
pub docent: String,
|
||||||
|
|
||||||
|
/// Course language
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub lang: Language<'static>,
|
||||||
|
|
||||||
|
// coding options
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub transcode_start: Resolution,
|
||||||
|
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||||
|
pub transcode: Option<Resolution>
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset_23ws_malo2() -> Preset {
|
||||||
|
Preset {
|
||||||
|
course: "23ws-malo2".into(),
|
||||||
|
label: "Mathematische Logik II".into(),
|
||||||
|
docent: "Prof. E. Grädel".into(),
|
||||||
|
lang: GERMAN,
|
||||||
|
transcode_start: "1440p".parse().unwrap(),
|
||||||
|
transcode: Some("360p".parse().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset_24ss_algomod() -> Preset {
|
||||||
|
Preset {
|
||||||
|
course: "24ss-algomod".into(),
|
||||||
|
label: "Algorithmische Modelltheorie".into(),
|
||||||
|
docent: "Prof. E. Grädel".into(),
|
||||||
|
lang: GERMAN,
|
||||||
|
transcode_start: "1440p".parse().unwrap(),
|
||||||
|
transcode: Some("720p".parse().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset_24ss_qc() -> Preset {
|
||||||
|
Preset {
|
||||||
|
course: "24ss-qc".into(),
|
||||||
|
label: "Introduction to Quantum Computing".into(),
|
||||||
|
docent: "Prof. D. Unruh".into(),
|
||||||
|
lang: BRITISH,
|
||||||
|
transcode_start: "1440p".parse().unwrap(),
|
||||||
|
transcode: Some("720p".parse().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preset {
|
||||||
|
pub fn find(name: &str) -> anyhow::Result<Self> {
|
||||||
|
match fs::read(name) {
|
||||||
|
Ok(buf) => return Ok(toml::from_slice(&buf)?),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {},
|
||||||
|
Err(err) => return Err(err.into())
|
||||||
|
}
|
||||||
|
Ok(match name {
|
||||||
|
"23ws-malo2" => preset_23ws_malo2(),
|
||||||
|
"24ss-algomod" => preset_24ss_algomod(),
|
||||||
|
"24ss-qc" => preset_24ss_qc(),
|
||||||
|
_ => bail!("Unknown preset {name:?}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
206
src/project.rs
Normal file
206
src/project.rs
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
//! Defines the structure of the `project.toml` file.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
iotro::Language,
|
||||||
|
render::ffmpeg::FfmpegOutputFormat,
|
||||||
|
time::{Date, Time}
|
||||||
|
};
|
||||||
|
use rational::Rational;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use std::{
|
||||||
|
cmp,
|
||||||
|
collections::BTreeSet,
|
||||||
|
fmt::{self, Display, Formatter},
|
||||||
|
str::FromStr
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Resolution(u32, u32);
|
||||||
|
|
||||||
|
impl Resolution {
|
||||||
|
pub fn new(width: u32, height: u32) -> Self {
|
||||||
|
Self(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(self) -> u32 {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bitrate(self) -> u64 {
|
||||||
|
// 640 * 360: 500k
|
||||||
|
if self.width() <= 640 {
|
||||||
|
500_000
|
||||||
|
}
|
||||||
|
// 1280 * 720: 1M
|
||||||
|
else if self.width() <= 1280 {
|
||||||
|
1_000_000
|
||||||
|
}
|
||||||
|
// 1920 * 1080: 2M
|
||||||
|
else if self.width() <= 1920 {
|
||||||
|
2_000_000
|
||||||
|
}
|
||||||
|
// 2560 * 1440: 3M
|
||||||
|
else if self.width() <= 2560 {
|
||||||
|
3_000_000
|
||||||
|
}
|
||||||
|
// 3840 * 2160: 4M
|
||||||
|
// TODO die bitrate von 4M ist absolut an den haaren herbeigezogen
|
||||||
|
else if self.width() <= 3840 {
|
||||||
|
4_000_000
|
||||||
|
}
|
||||||
|
// we'll cap everything else at 5M for no apparent reason
|
||||||
|
else {
|
||||||
|
5_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_codec(self) -> FfmpegOutputFormat {
|
||||||
|
if self.width() > 1920 {
|
||||||
|
FfmpegOutputFormat::Av1Opus
|
||||||
|
} else {
|
||||||
|
FfmpegOutputFormat::AvcAac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const STANDARD_RESOLUTIONS: [Self; 5] = [
|
||||||
|
Self(640, 360),
|
||||||
|
Self(1280, 720),
|
||||||
|
Self(1920, 1080),
|
||||||
|
Self(2560, 1440),
|
||||||
|
Self(3840, 2160)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Resolution {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}p", self.height())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Resolution {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||||
|
Ok(match s.to_lowercase().as_str() {
|
||||||
|
"360p" | "nhd" => Self(640, 360),
|
||||||
|
"540p" | "qhd" => Self(960, 540),
|
||||||
|
"720p" | "hd" => Self(1280, 720),
|
||||||
|
"900p" | "hd+" => Self(1600, 900),
|
||||||
|
"1080p" | "fhd" | "fullhd" => Self(1920, 1080),
|
||||||
|
"1440p" | "wqhd" => Self(2560, 1440),
|
||||||
|
"2160p" | "4k" | "uhd" => Self(3840, 2160),
|
||||||
|
_ => anyhow::bail!("Unknown Resolution: {s:?}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Resolution {
|
||||||
|
fn cmp(&self, other: &Self) -> cmp::Ordering {
|
||||||
|
(self.0 * self.1).cmp(&(other.0 * other.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Resolution {}
|
||||||
|
|
||||||
|
impl PartialOrd for Resolution {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Resolution {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.cmp(other) == cmp::Ordering::Equal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Project {
|
||||||
|
pub lecture: ProjectLecture,
|
||||||
|
pub source: ProjectSource,
|
||||||
|
pub progress: ProjectProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ProjectLecture {
|
||||||
|
pub course: String,
|
||||||
|
pub label: String,
|
||||||
|
pub docent: String,
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub date: Date,
|
||||||
|
#[serde(default = "Default::default")]
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub lang: Language<'static>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ProjectSource {
|
||||||
|
pub files: Vec<String>,
|
||||||
|
pub stereo: bool,
|
||||||
|
|
||||||
|
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||||
|
pub start: Option<Time>,
|
||||||
|
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||||
|
pub end: Option<Time>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
|
||||||
|
pub fast: Vec<(Time, Time)>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr, _)>")]
|
||||||
|
pub questions: Vec<(Time, Time, String)>,
|
||||||
|
|
||||||
|
pub metadata: Option<ProjectSourceMetadata>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ProjectSourceMetadata {
|
||||||
|
/// The duration of the source video.
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub source_duration: Time,
|
||||||
|
/// The FPS of the source video.
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub source_fps: Rational,
|
||||||
|
/// The time base of the source video.
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
pub source_tbn: Rational,
|
||||||
|
/// The resolution of the source video.
|
||||||
|
pub source_res: Resolution,
|
||||||
|
/// The sample rate of the source audio.
|
||||||
|
pub source_sample_rate: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct ProjectProgress {
|
||||||
|
#[serde(default)]
|
||||||
|
pub preprocessed: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub asked_start_end: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub asked_fast: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub asked_questions: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub rendered_assets: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub rendered: bool,
|
||||||
|
|
||||||
|
#[serde_as(as = "BTreeSet<DisplayFromStr>")]
|
||||||
|
#[serde(default)]
|
||||||
|
pub transcoded: BTreeSet<Resolution>
|
||||||
|
}
|
140
src/question.rs
Normal file
140
src/question.rs
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
use crate::{iotro::Language, project::Resolution};
|
||||||
|
use fontconfig::Fontconfig;
|
||||||
|
use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use svgwriter::{
|
||||||
|
tags::{Group, Path, TSpan, TagWithPresentationAttributes as _, Text},
|
||||||
|
Data, Graphic, Transform
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) struct Question {
|
||||||
|
res: Resolution,
|
||||||
|
g: Group
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Question {
|
||||||
|
pub(crate) fn new(res: Resolution, lang: &Language<'_>, str: &str) -> Self {
|
||||||
|
static FONT: OnceLock<Owned<Font<'static>>> = OnceLock::new();
|
||||||
|
let font = FONT.get_or_init(|| {
|
||||||
|
let fc = Fontconfig::new().unwrap();
|
||||||
|
let font_path = fc.find("Noto Sans", None).unwrap().path;
|
||||||
|
let face = Face::from_file(font_path, 0).unwrap();
|
||||||
|
Font::new(face)
|
||||||
|
});
|
||||||
|
let upem = font.face().upem();
|
||||||
|
|
||||||
|
// constants
|
||||||
|
let border_r = 12;
|
||||||
|
let font_size = 44;
|
||||||
|
let line_height = font_size * 6 / 5;
|
||||||
|
let padding = font_size / 2;
|
||||||
|
let margin_x = 240;
|
||||||
|
let margin_y = padding * 3 / 2;
|
||||||
|
let question_offset = 64;
|
||||||
|
let question_width = 240;
|
||||||
|
|
||||||
|
// calculated
|
||||||
|
let box_width = 1920 - 2 * margin_x;
|
||||||
|
let text_width = box_width - 2 * padding;
|
||||||
|
|
||||||
|
// calculates the width of the given string
|
||||||
|
let width_of = |s: &str| {
|
||||||
|
let width: i32 =
|
||||||
|
harfbuzz_rs::shape(font, UnicodeBuffer::new().add_str(s), &[])
|
||||||
|
.get_glyph_positions()
|
||||||
|
.iter()
|
||||||
|
.map(|glyph_pos| glyph_pos.x_advance)
|
||||||
|
.sum();
|
||||||
|
(width * font_size) / upem as i32
|
||||||
|
};
|
||||||
|
let space_width = width_of(" ");
|
||||||
|
|
||||||
|
// lay out the text
|
||||||
|
let mut text = Text::new()
|
||||||
|
.with_dominant_baseline("hanging")
|
||||||
|
.with_transform(
|
||||||
|
Transform::new().translate(padding, padding + font_size / 2 + border_r)
|
||||||
|
);
|
||||||
|
let words = str.split_whitespace();
|
||||||
|
let mut text_height = 0;
|
||||||
|
let mut text_x = 0;
|
||||||
|
for word in words {
|
||||||
|
let word_width = width_of(word);
|
||||||
|
if text_x + word_width > text_width {
|
||||||
|
text_x = 0;
|
||||||
|
text_height += line_height;
|
||||||
|
}
|
||||||
|
text.push(
|
||||||
|
TSpan::new()
|
||||||
|
.with_x(text_x)
|
||||||
|
.with_y(text_height)
|
||||||
|
.append(word.to_owned())
|
||||||
|
);
|
||||||
|
text_x += word_width + space_width;
|
||||||
|
}
|
||||||
|
text_height += font_size;
|
||||||
|
|
||||||
|
// calculated
|
||||||
|
let box_height = text_height + 2 * padding + font_size / 2 + border_r;
|
||||||
|
|
||||||
|
let mut g = Group::new()
|
||||||
|
.with_fill("white")
|
||||||
|
.with_font_family("Noto Sans")
|
||||||
|
.with_font_size(font_size)
|
||||||
|
.with_transform(
|
||||||
|
Transform::new().translate(margin_x, 1080 - margin_y - box_height)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut outline = Data::new();
|
||||||
|
outline.move_by(border_r, 0).horiz_line_to(question_offset);
|
||||||
|
outline
|
||||||
|
.vert_line_by(-font_size / 2)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, border_r, -border_r)
|
||||||
|
.horiz_line_by(question_width)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, border_r, border_r)
|
||||||
|
.vert_line_by(font_size)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, -border_r, border_r)
|
||||||
|
.horiz_line_by(-question_width)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, -border_r, -border_r)
|
||||||
|
.vert_line_by(-font_size / 2)
|
||||||
|
.move_by(question_width + 2 * border_r, 0);
|
||||||
|
outline
|
||||||
|
.horiz_line_to(box_width - border_r)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, border_r, border_r)
|
||||||
|
.vert_line_by(box_height - 2 * border_r)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, -border_r, border_r)
|
||||||
|
.horiz_line_to(border_r)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, -border_r, -border_r)
|
||||||
|
.vert_line_to(border_r)
|
||||||
|
.arc_by(border_r, border_r, 0, false, true, border_r, -border_r);
|
||||||
|
g.push(
|
||||||
|
Path::new()
|
||||||
|
.with_stroke("#fff")
|
||||||
|
.with_stroke_width(3)
|
||||||
|
.with_fill("#000")
|
||||||
|
.with_fill_opacity(".3")
|
||||||
|
.with_d(outline)
|
||||||
|
);
|
||||||
|
g.push(
|
||||||
|
Text::new()
|
||||||
|
.with_x(question_offset + question_width / 2 + border_r)
|
||||||
|
.with_y(0)
|
||||||
|
.with_dominant_baseline("middle")
|
||||||
|
.with_text_anchor("middle")
|
||||||
|
.with_font_weight(600)
|
||||||
|
.append(lang.question.to_owned())
|
||||||
|
);
|
||||||
|
g.push(text);
|
||||||
|
|
||||||
|
Self { res, g }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn finish(self) -> Graphic {
|
||||||
|
let mut svg = Graphic::new();
|
||||||
|
svg.set_width(self.res.width());
|
||||||
|
svg.set_height(self.res.height());
|
||||||
|
svg.set_view_box("0 0 1920 1080");
|
||||||
|
svg.push(self.g);
|
||||||
|
svg
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
use super::{cmd, filter::Filter};
|
use super::{cmd, filter::Filter};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
project::Resolution,
|
||||||
render::filter::channel,
|
render::filter::channel,
|
||||||
time::{format_time, Time}
|
time::{format_time, Time}
|
||||||
};
|
};
|
||||||
|
@ -40,7 +41,9 @@ impl FfmpegInput {
|
||||||
cmd.arg("-r").arg(fps.to_string());
|
cmd.arg("-r").arg(fps.to_string());
|
||||||
}
|
}
|
||||||
if let Some(start) = self.start {
|
if let Some(start) = self.start {
|
||||||
cmd.arg("-seek_streams_individually").arg("false");
|
if self.path.ends_with(".mp4") || self.path.ends_with(".mov") {
|
||||||
|
cmd.arg("-seek_streams_individually").arg("false");
|
||||||
|
}
|
||||||
cmd.arg("-ss").arg(format_time(start));
|
cmd.arg("-ss").arg(format_time(start));
|
||||||
}
|
}
|
||||||
if let Some(duration) = self.duration {
|
if let Some(duration) = self.duration {
|
||||||
|
@ -50,25 +53,197 @@ impl FfmpegInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum FfmpegOutputFormat {
|
||||||
|
/// AV1 / FLAC
|
||||||
|
Av1Flac,
|
||||||
|
/// AV1 / OPUS
|
||||||
|
Av1Opus,
|
||||||
|
/// AVC (H.264) / FLAC
|
||||||
|
AvcFlac,
|
||||||
|
/// AVC (H.264) / AAC
|
||||||
|
AvcAac
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FfmpegOutputFormat {
|
||||||
|
pub(crate) fn with_flac_audio(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Av1Flac | Self::AvcFlac => self,
|
||||||
|
Self::Av1Opus => Self::Av1Flac,
|
||||||
|
Self::AvcAac => Self::AvcFlac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum FfmpegOutputQuality {
|
||||||
|
Default,
|
||||||
|
VisuallyLossless
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct FfmpegOutput {
|
||||||
|
pub(crate) format: FfmpegOutputFormat,
|
||||||
|
pub(crate) quality: FfmpegOutputQuality,
|
||||||
|
pub(crate) audio_bitrate: Option<u64>,
|
||||||
|
pub(crate) video_bitrate: Option<u64>,
|
||||||
|
|
||||||
|
pub(crate) fps: Option<Rational>,
|
||||||
|
pub(crate) duration: Option<Time>,
|
||||||
|
pub(crate) time_base: Option<Rational>,
|
||||||
|
pub(crate) fps_mode_vfr: bool,
|
||||||
|
pub(crate) faststart: bool,
|
||||||
|
|
||||||
|
// video container metadata
|
||||||
|
pub(crate) title: Option<String>,
|
||||||
|
pub(crate) author: Option<String>,
|
||||||
|
pub(crate) album: Option<String>,
|
||||||
|
pub(crate) year: Option<String>,
|
||||||
|
pub(crate) comment: Option<String>,
|
||||||
|
pub(crate) language: Option<String>,
|
||||||
|
|
||||||
|
pub(crate) path: PathBuf
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FfmpegOutput {
|
||||||
|
pub(crate) fn new(format: FfmpegOutputFormat, path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
format,
|
||||||
|
quality: FfmpegOutputQuality::Default,
|
||||||
|
audio_bitrate: None,
|
||||||
|
video_bitrate: None,
|
||||||
|
|
||||||
|
fps: None,
|
||||||
|
duration: None,
|
||||||
|
time_base: None,
|
||||||
|
fps_mode_vfr: false,
|
||||||
|
faststart: false,
|
||||||
|
|
||||||
|
title: None,
|
||||||
|
author: None,
|
||||||
|
album: None,
|
||||||
|
year: None,
|
||||||
|
comment: None,
|
||||||
|
language: None,
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn enable_faststart(mut self) -> Self {
|
||||||
|
// only enable faststart for MP4 containers
|
||||||
|
if matches!(self.format, FfmpegOutputFormat::AvcAac) {
|
||||||
|
self.faststart = true;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_to_cmd(self, cmd: &mut Command, venc: bool, _aenc: bool, vaapi: bool) {
|
||||||
|
// select codec and bitrate/crf
|
||||||
|
if venc {
|
||||||
|
let vcodec = match (self.format, vaapi) {
|
||||||
|
(FfmpegOutputFormat::Av1Flac, false)
|
||||||
|
| (FfmpegOutputFormat::Av1Opus, false) => "libsvtav1",
|
||||||
|
(FfmpegOutputFormat::Av1Flac, true)
|
||||||
|
| (FfmpegOutputFormat::Av1Opus, true) => "av1_vaapi",
|
||||||
|
(FfmpegOutputFormat::AvcAac, false)
|
||||||
|
| (FfmpegOutputFormat::AvcFlac, false) => "h264",
|
||||||
|
(FfmpegOutputFormat::AvcAac, true)
|
||||||
|
| (FfmpegOutputFormat::AvcFlac, true) => "h264_vaapi"
|
||||||
|
};
|
||||||
|
cmd.arg("-c:v").arg(vcodec);
|
||||||
|
|
||||||
|
if vcodec == "libsvtav1" {
|
||||||
|
cmd.arg("-svtav1-params").arg("fast-decode=1");
|
||||||
|
cmd.arg("-preset").arg("7");
|
||||||
|
cmd.arg("-crf").arg(match self.quality {
|
||||||
|
FfmpegOutputQuality::Default => "28",
|
||||||
|
FfmpegOutputQuality::VisuallyLossless => "18"
|
||||||
|
});
|
||||||
|
} else if vcodec == "h264" {
|
||||||
|
match self.quality {
|
||||||
|
FfmpegOutputQuality::Default => {
|
||||||
|
cmd.arg("-preset").arg("slow");
|
||||||
|
cmd.arg("-crf").arg("21");
|
||||||
|
},
|
||||||
|
FfmpegOutputQuality::VisuallyLossless => {
|
||||||
|
// the quality is not impacted by speed, only the bitrate, and
|
||||||
|
// for this setting we don't really care about bitrate
|
||||||
|
cmd.arg("-preset").arg("veryfast");
|
||||||
|
cmd.arg("-crf").arg("17");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(bv) = self.video_bitrate {
|
||||||
|
cmd.arg("-b:v").arg(bv.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cmd.arg("-c:v").arg("copy");
|
||||||
|
}
|
||||||
|
cmd.arg("-c:a").arg(match self.format {
|
||||||
|
FfmpegOutputFormat::Av1Flac | FfmpegOutputFormat::AvcFlac => "flac",
|
||||||
|
FfmpegOutputFormat::Av1Opus => "libopus",
|
||||||
|
FfmpegOutputFormat::AvcAac => "aac"
|
||||||
|
});
|
||||||
|
if let Some(ba) = self.audio_bitrate {
|
||||||
|
cmd.arg("-b:a").arg(ba.to_string());
|
||||||
|
} else if !matches!(self.format, FfmpegOutputFormat::Av1Flac) {
|
||||||
|
cmd.arg("-b:a").arg("128k");
|
||||||
|
}
|
||||||
|
|
||||||
|
// other output options
|
||||||
|
if let Some(fps) = self.fps {
|
||||||
|
cmd.arg("-r").arg(fps.to_string());
|
||||||
|
}
|
||||||
|
if let Some(duration) = self.duration {
|
||||||
|
cmd.arg("-t").arg(format_time(duration));
|
||||||
|
}
|
||||||
|
if let Some(time_base) = self.time_base {
|
||||||
|
cmd.arg("-enc_time_base").arg(time_base.to_string());
|
||||||
|
}
|
||||||
|
if self.fps_mode_vfr {
|
||||||
|
cmd.arg("-fps_mode").arg("vfr");
|
||||||
|
}
|
||||||
|
if self.faststart {
|
||||||
|
cmd.arg("-movflags").arg("+faststart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadata
|
||||||
|
macro_rules! add_meta {
|
||||||
|
($this:ident, $cmd:ident: $($meta:ident),+) => {
|
||||||
|
$(if let Some(value) = $this.$meta.as_deref() {
|
||||||
|
$cmd.arg("-metadata").arg(format!("{}={}", stringify!($meta), value));
|
||||||
|
})+
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add_meta!(self, cmd: title, author, album, year, comment, language);
|
||||||
|
|
||||||
|
cmd.arg(self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FfmpegFilter {
|
||||||
|
None,
|
||||||
|
Filters {
|
||||||
|
filters: Vec<Filter>,
|
||||||
|
output: Cow<'static, str>
|
||||||
|
},
|
||||||
|
Loudnorm {
|
||||||
|
stereo: bool
|
||||||
|
},
|
||||||
|
Rescale(Resolution)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct Ffmpeg {
|
pub(crate) struct Ffmpeg {
|
||||||
inputs: Vec<FfmpegInput>,
|
inputs: Vec<FfmpegInput>,
|
||||||
filters: Vec<Filter>,
|
filter: FfmpegFilter,
|
||||||
filters_output: Cow<'static, str>,
|
output: FfmpegOutput,
|
||||||
loudnorm: bool,
|
|
||||||
duration: Option<Time>,
|
|
||||||
output: PathBuf,
|
|
||||||
|
|
||||||
filter_idx: usize
|
filter_idx: usize
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ffmpeg {
|
impl Ffmpeg {
|
||||||
pub fn new(output: PathBuf) -> Self {
|
pub fn new(output: FfmpegOutput) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inputs: Vec::new(),
|
inputs: Vec::new(),
|
||||||
filters: Vec::new(),
|
filter: FfmpegFilter::None,
|
||||||
filters_output: "0".into(),
|
|
||||||
loudnorm: false,
|
|
||||||
duration: None,
|
|
||||||
output,
|
output,
|
||||||
|
|
||||||
filter_idx: 0
|
filter_idx: 0
|
||||||
|
@ -81,27 +256,54 @@ impl Ffmpeg {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_filter(&mut self, filter: Filter) -> &mut Self {
|
pub fn add_filter(&mut self, filter: Filter) -> &mut Self {
|
||||||
assert!(!self.loudnorm);
|
match &mut self.filter {
|
||||||
self.filters.push(filter);
|
FfmpegFilter::None => {
|
||||||
|
self.filter = FfmpegFilter::Filters {
|
||||||
|
filters: vec![filter],
|
||||||
|
output: "0".into()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FfmpegFilter::Filters { filters, .. } => filters.push(filter),
|
||||||
|
_ => panic!("An incompatible type of filter has been set before")
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_filter_output<T: Into<Cow<'static, str>>>(
|
pub fn set_filter_output<T: Into<Cow<'static, str>>>(
|
||||||
&mut self,
|
&mut self,
|
||||||
output: T
|
filter_output: T
|
||||||
) -> &mut Self {
|
) -> &mut Self {
|
||||||
self.filters_output = output.into();
|
match &mut self.filter {
|
||||||
|
FfmpegFilter::None => {
|
||||||
|
self.filter = FfmpegFilter::Filters {
|
||||||
|
filters: vec![],
|
||||||
|
output: filter_output.into()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FfmpegFilter::Filters { output, .. } => *output = filter_output.into(),
|
||||||
|
_ => panic!("An incompatible type of filter has been set before")
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enable_loudnorm(&mut self) -> &mut Self {
|
pub fn enable_loudnorm(&mut self, loudnorm_stereo: bool) -> &mut Self {
|
||||||
assert!(self.filters.is_empty());
|
match &mut self.filter {
|
||||||
self.loudnorm = true;
|
FfmpegFilter::None => {
|
||||||
|
self.filter = FfmpegFilter::Loudnorm {
|
||||||
|
stereo: loudnorm_stereo
|
||||||
|
}
|
||||||
|
},
|
||||||
|
FfmpegFilter::Loudnorm { stereo } if *stereo == loudnorm_stereo => {},
|
||||||
|
_ => panic!("An incompatible type of filter has been set before")
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_duration(&mut self, duration: Time) -> &mut Self {
|
pub fn rescale_video(&mut self, res: Resolution) -> &mut Self {
|
||||||
self.duration = Some(duration);
|
match &mut self.filter {
|
||||||
|
FfmpegFilter::None => self.filter = FfmpegFilter::Rescale(res),
|
||||||
|
_ => panic!("An incompatible type of filter has been set before")
|
||||||
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,14 +312,25 @@ impl Ffmpeg {
|
||||||
cmd.arg("ffmpeg").arg("-hide_banner").arg("-y");
|
cmd.arg("ffmpeg").arg("-hide_banner").arg("-y");
|
||||||
|
|
||||||
// determine whether the video need to be re-encoded
|
// determine whether the video need to be re-encoded
|
||||||
let venc = !self.filters.is_empty();
|
// vdec is only true if the video should be decoded on hardware
|
||||||
let aenc = !self.filters.is_empty() || self.loudnorm;
|
let (vdec, venc, aenc) = match &self.filter {
|
||||||
|
FfmpegFilter::None => (false, false, false),
|
||||||
|
FfmpegFilter::Filters { .. } => (false, true, true),
|
||||||
|
FfmpegFilter::Loudnorm { .. } => (false, false, true),
|
||||||
|
FfmpegFilter::Rescale(_) => (true, true, false)
|
||||||
|
};
|
||||||
|
|
||||||
// initialise a vaapi device if one exists
|
// initialise a vaapi device if one exists
|
||||||
let vaapi_device: PathBuf = "/dev/dri/renderD128".into();
|
let vaapi_device: PathBuf = "/dev/dri/renderD128".into();
|
||||||
let vaapi = venc && vaapi_device.exists();
|
let vaapi = cfg!(feature = "vaapi") && vaapi_device.exists();
|
||||||
if vaapi {
|
if vaapi && venc {
|
||||||
cmd.arg("-vaapi_device").arg(&vaapi_device);
|
if vdec {
|
||||||
|
cmd.arg("-hwaccel").arg("vaapi");
|
||||||
|
cmd.arg("-hwaccel_device").arg(vaapi_device);
|
||||||
|
cmd.arg("-hwaccel_output_format").arg("vaapi");
|
||||||
|
} else {
|
||||||
|
cmd.arg("-vaapi_device").arg(&vaapi_device);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// append all the inputs
|
// append all the inputs
|
||||||
|
@ -129,55 +342,45 @@ impl Ffmpeg {
|
||||||
cmd.arg("-async").arg("1");
|
cmd.arg("-async").arg("1");
|
||||||
|
|
||||||
// apply filters
|
// apply filters
|
||||||
match (self.loudnorm, self.filters) {
|
match self.filter {
|
||||||
(true, f) if f.is_empty() => {
|
FfmpegFilter::None => {},
|
||||||
cmd.arg("-af").arg("pan=mono|c0=FR,loudnorm,pan=stereo|c0=c0|c1=c0,aformat=sample_rates=48000");
|
FfmpegFilter::Filters { filters, output } => {
|
||||||
},
|
|
||||||
(true, _) => panic!("Filters and loudnorm at the same time is not supported"),
|
|
||||||
|
|
||||||
(false, f) if f.is_empty() => {},
|
|
||||||
(false, f) => {
|
|
||||||
let mut complex = String::new();
|
let mut complex = String::new();
|
||||||
for filter in f {
|
for filter in filters {
|
||||||
filter.append_to_complex_filter(&mut complex, &mut self.filter_idx);
|
filter
|
||||||
|
.append_to_complex_filter(&mut complex, &mut self.filter_idx)?;
|
||||||
}
|
}
|
||||||
if vaapi {
|
if vaapi {
|
||||||
write!(
|
write!(complex, "{}format=nv12,hwupload[v]", channel('v', &output))?;
|
||||||
complex,
|
|
||||||
"{}format=nv12,hwupload[v]",
|
|
||||||
channel('v', &self.filters_output)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
write!(complex, "{}null[v]", channel('v', &self.filters_output));
|
write!(complex, "{}null[v]", channel('v', &output))?;
|
||||||
}
|
}
|
||||||
cmd.arg("-filter_complex").arg(complex);
|
cmd.arg("-filter_complex").arg(complex);
|
||||||
cmd.arg("-map").arg("[v]");
|
cmd.arg("-map").arg("[v]");
|
||||||
cmd.arg("-map").arg(channel('a', &self.filters_output));
|
cmd.arg("-map").arg(channel('a', &output));
|
||||||
|
},
|
||||||
|
FfmpegFilter::Loudnorm { stereo: false } => {
|
||||||
|
cmd.arg("-af").arg(concat!(
|
||||||
|
"pan=mono|c0=FL,",
|
||||||
|
"loudnorm=dual_mono=true:print_format=summary,",
|
||||||
|
"pan=stereo|c0=c0|c1=c0,",
|
||||||
|
"aformat=sample_rates=48000"
|
||||||
|
));
|
||||||
|
},
|
||||||
|
FfmpegFilter::Loudnorm { stereo: true } => {
|
||||||
|
cmd.arg("-af")
|
||||||
|
.arg("loudnorm=print_format=summary,aformat=sample_rates=48000");
|
||||||
|
},
|
||||||
|
FfmpegFilter::Rescale(res) => {
|
||||||
|
cmd.arg("-vf").arg(if vaapi {
|
||||||
|
format!("scale_vaapi=w={}:h={}", res.width(), res.height())
|
||||||
|
} else {
|
||||||
|
format!("scale=w={}:h={}", res.width(), res.height())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// append encoding options
|
self.output.append_to_cmd(&mut cmd, venc, aenc, vaapi);
|
||||||
if vaapi {
|
|
||||||
cmd.arg("-c:v").arg("h264_vaapi");
|
|
||||||
cmd.arg("-rc_mode").arg("CQP");
|
|
||||||
cmd.arg("-global_quality").arg("24");
|
|
||||||
} else if venc {
|
|
||||||
cmd.arg("-c:v").arg("libx264");
|
|
||||||
cmd.arg("-crf").arg("22");
|
|
||||||
} else {
|
|
||||||
cmd.arg("-c:v").arg("copy");
|
|
||||||
}
|
|
||||||
if aenc {
|
|
||||||
cmd.arg("-c:a").arg("aac");
|
|
||||||
cmd.arg("-b:a").arg("128000");
|
|
||||||
} else {
|
|
||||||
cmd.arg("-c:a").arg("copy");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(duration) = self.duration {
|
|
||||||
cmd.arg("-t").arg(format_time(duration));
|
|
||||||
}
|
|
||||||
cmd.arg(&self.output);
|
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
if status.success() {
|
if status.success() {
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
use crate::time::{format_time, Time};
|
use crate::time::Time;
|
||||||
use std::{borrow::Cow, collections::VecDeque, fmt::Write as _};
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
collections::VecDeque,
|
||||||
|
fmt::{self, Write as _}
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) enum Filter {
|
pub(crate) enum Filter {
|
||||||
/// Trim audio and video alike
|
|
||||||
Trim {
|
|
||||||
input: Cow<'static, str>,
|
|
||||||
start: Option<Time>,
|
|
||||||
duration: Option<Time>,
|
|
||||||
output: Cow<'static, str>
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Apply an alpha channel on the video. No audio.
|
/// Apply an alpha channel on the video. No audio.
|
||||||
Alpha {
|
Alpha {
|
||||||
input: Cow<'static, str>,
|
input: Cow<'static, str>,
|
||||||
|
@ -23,6 +19,7 @@ pub(crate) enum Filter {
|
||||||
overlay_input: Cow<'static, str>,
|
overlay_input: Cow<'static, str>,
|
||||||
x: Cow<'static, str>,
|
x: Cow<'static, str>,
|
||||||
y: Cow<'static, str>,
|
y: Cow<'static, str>,
|
||||||
|
repeatlast: bool,
|
||||||
output: Cow<'static, str>
|
output: Cow<'static, str>
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -41,6 +38,22 @@ pub(crate) enum Filter {
|
||||||
output: Cow<'static, str>
|
output: Cow<'static, str>
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Fade only video using the alpha channel.
|
||||||
|
FadeAlpha {
|
||||||
|
input: Cow<'static, str>,
|
||||||
|
direction: &'static str,
|
||||||
|
start: Time,
|
||||||
|
duration: Time,
|
||||||
|
output: Cow<'static, str>
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Offset the PTS of the video by the amount of seconds.
|
||||||
|
VideoOffset {
|
||||||
|
input: Cow<'static, str>,
|
||||||
|
seconds: Time,
|
||||||
|
output: Cow<'static, str>
|
||||||
|
},
|
||||||
|
|
||||||
/// Generate silence. The video is copied.
|
/// Generate silence. The video is copied.
|
||||||
GenerateSilence {
|
GenerateSilence {
|
||||||
video: Cow<'static, str>,
|
video: Cow<'static, str>,
|
||||||
|
@ -57,63 +70,12 @@ pub(crate) enum Filter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Filter {
|
impl Filter {
|
||||||
pub(crate) fn is_video_filter(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Trim { .. }
|
|
||||||
| Self::Alpha { .. }
|
|
||||||
| Self::Concat { .. }
|
|
||||||
| Self::Fade { .. }
|
|
||||||
| Self::Overlay { .. }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_audio_filter(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Trim { .. }
|
|
||||||
| Self::Concat { .. }
|
|
||||||
| Self::Fade { .. }
|
|
||||||
| Self::GenerateSilence { .. }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn append_to_complex_filter(
|
pub(crate) fn append_to_complex_filter(
|
||||||
&self,
|
&self,
|
||||||
complex: &mut String,
|
complex: &mut String,
|
||||||
filter_idx: &mut usize
|
filter_idx: &mut usize
|
||||||
) {
|
) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Trim {
|
|
||||||
input,
|
|
||||||
start,
|
|
||||||
duration,
|
|
||||||
output
|
|
||||||
} => {
|
|
||||||
let mut args = String::new();
|
|
||||||
if let Some(start) = start {
|
|
||||||
write!(args, "start={start}");
|
|
||||||
}
|
|
||||||
if let Some(duration) = duration {
|
|
||||||
if !args.is_empty() {
|
|
||||||
args += ":";
|
|
||||||
}
|
|
||||||
write!(args, "duration={duration}");
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
complex,
|
|
||||||
"{}trim={args},setpts=PTS-STARTPTS{};",
|
|
||||||
channel('v', input),
|
|
||||||
channel('v', output)
|
|
||||||
);
|
|
||||||
writeln!(
|
|
||||||
complex,
|
|
||||||
"{}atrim={args},asetpts=PTS-STARTPTS{};",
|
|
||||||
channel('a', input),
|
|
||||||
channel('a', output)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
Self::Alpha {
|
Self::Alpha {
|
||||||
input,
|
input,
|
||||||
alpha,
|
alpha,
|
||||||
|
@ -124,7 +86,7 @@ impl Filter {
|
||||||
"{}format=yuva444p,colorchannelmixer=aa={alpha}{};",
|
"{}format=yuva444p,colorchannelmixer=aa={alpha}{};",
|
||||||
channel('v', input),
|
channel('v', input),
|
||||||
channel('v', output)
|
channel('v', output)
|
||||||
);
|
)?;
|
||||||
},
|
},
|
||||||
|
|
||||||
Self::Overlay {
|
Self::Overlay {
|
||||||
|
@ -132,26 +94,28 @@ impl Filter {
|
||||||
overlay_input,
|
overlay_input,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
repeatlast,
|
||||||
output
|
output
|
||||||
} => {
|
} => {
|
||||||
|
let repeatlast: u8 = (*repeatlast).into();
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
"{}{}overlay=x={x}:y={y}{};",
|
"{}{}overlay=x={x}:y={y}:repeatlast={repeatlast}:eval=init{};",
|
||||||
channel('v', video_input),
|
channel('v', video_input),
|
||||||
channel('v', overlay_input),
|
channel('v', overlay_input),
|
||||||
channel('v', output)
|
channel('v', output)
|
||||||
);
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
"{}anull{};",
|
"{}anull{};",
|
||||||
channel('a', video_input),
|
channel('a', video_input),
|
||||||
channel('a', output)
|
channel('a', output)
|
||||||
);
|
)?;
|
||||||
},
|
},
|
||||||
|
|
||||||
Self::Concat { inputs, output } => {
|
Self::Concat { inputs, output } => {
|
||||||
for i in inputs {
|
for i in inputs {
|
||||||
write!(complex, "{}{}", channel('v', i), channel('a', i));
|
write!(complex, "{}{}", channel('v', i), channel('a', i))?;
|
||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
|
@ -159,7 +123,7 @@ impl Filter {
|
||||||
inputs.len(),
|
inputs.len(),
|
||||||
channel('v', output),
|
channel('v', output),
|
||||||
channel('a', output)
|
channel('a', output)
|
||||||
);
|
)?;
|
||||||
},
|
},
|
||||||
|
|
||||||
Self::Fade {
|
Self::Fade {
|
||||||
|
@ -175,13 +139,41 @@ impl Filter {
|
||||||
"{}fade={args}{};",
|
"{}fade={args}{};",
|
||||||
channel('v', input),
|
channel('v', input),
|
||||||
channel('v', output)
|
channel('v', output)
|
||||||
);
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
"{}afade=t={args}{};",
|
"{}afade=t={args}{};",
|
||||||
channel('a', input),
|
channel('a', input),
|
||||||
channel('a', output)
|
channel('a', output)
|
||||||
);
|
)?;
|
||||||
|
},
|
||||||
|
|
||||||
|
Self::FadeAlpha {
|
||||||
|
input,
|
||||||
|
direction,
|
||||||
|
start,
|
||||||
|
duration,
|
||||||
|
output
|
||||||
|
} => {
|
||||||
|
writeln!(
|
||||||
|
complex,
|
||||||
|
"{}fade={direction}:st={start}:d={duration}:alpha=1{};",
|
||||||
|
channel('v', input),
|
||||||
|
channel('v', output)
|
||||||
|
)?;
|
||||||
|
},
|
||||||
|
|
||||||
|
Self::VideoOffset {
|
||||||
|
input,
|
||||||
|
seconds,
|
||||||
|
output
|
||||||
|
} => {
|
||||||
|
writeln!(
|
||||||
|
complex,
|
||||||
|
"{}setpts=PTS+{seconds}/TB{};",
|
||||||
|
channel('v', input),
|
||||||
|
channel('v', output)
|
||||||
|
)?;
|
||||||
},
|
},
|
||||||
|
|
||||||
Self::GenerateSilence { video, output } => {
|
Self::GenerateSilence { video, output } => {
|
||||||
|
@ -190,8 +182,12 @@ impl Filter {
|
||||||
"{}null{};",
|
"{}null{};",
|
||||||
channel('v', video),
|
channel('v', video),
|
||||||
channel('v', output)
|
channel('v', output)
|
||||||
);
|
)?;
|
||||||
writeln!(complex, "aevalsrc=0:s=48000{};", channel('a', output));
|
writeln!(
|
||||||
|
complex,
|
||||||
|
"aevalsrc=0:s=48000,pan=stereo|c0=c0|c1=c0{};",
|
||||||
|
channel('a', output)
|
||||||
|
)?;
|
||||||
},
|
},
|
||||||
|
|
||||||
Self::FastForward {
|
Self::FastForward {
|
||||||
|
@ -205,29 +201,29 @@ impl Filter {
|
||||||
complex,
|
complex,
|
||||||
"{}setpts=PTS/{multiplier}{vff};",
|
"{}setpts=PTS/{multiplier}{vff};",
|
||||||
channel('v', input)
|
channel('v', input)
|
||||||
);
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
"{}atempo={multiplier}{};",
|
"{}atempo={multiplier}{};",
|
||||||
channel('a', input),
|
channel('a', input),
|
||||||
channel('a', output)
|
channel('a', output)
|
||||||
);
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
complex,
|
complex,
|
||||||
"{vff}{}overlay=x=main_w/2-overlay_w/2:y=main_h/2-overlay_h/2{};",
|
"{vff}{}overlay=x=main_w/2-overlay_w/2:y=main_h/2-overlay_h/2{};",
|
||||||
channel('v', ffinput),
|
channel('v', ffinput),
|
||||||
channel('v', output)
|
channel('v', output)
|
||||||
);
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add a newline after every filter to ease debugging
|
// add a newline after every filter to ease debugging
|
||||||
writeln!(complex);
|
writeln!(complex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn channel(channel: char, id: &str) -> String {
|
pub(super) fn channel(channel: char, id: &str) -> String {
|
||||||
if id.chars().any(|ch| !ch.is_digit(10)) {
|
if id.chars().any(|ch| !ch.is_ascii_digit()) {
|
||||||
format!("[{channel}_{id}]")
|
format!("[{channel}_{id}]")
|
||||||
} else {
|
} else {
|
||||||
format!("[{id}:{channel}]")
|
format!("[{id}:{channel}]")
|
||||||
|
@ -238,11 +234,3 @@ fn next_tmp(filter_idx: &mut usize) -> String {
|
||||||
*filter_idx += 1;
|
*filter_idx += 1;
|
||||||
format!("[tmp{filter_idx}]")
|
format!("[tmp{filter_idx}]")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_tmp_3(filter_idx: &mut usize) -> [String; 3] {
|
|
||||||
[
|
|
||||||
next_tmp(filter_idx),
|
|
||||||
next_tmp(filter_idx),
|
|
||||||
next_tmp(filter_idx)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,18 +1,21 @@
|
||||||
#![allow(warnings)]
|
|
||||||
|
|
||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
mod filter;
|
mod filter;
|
||||||
|
|
||||||
use self::filter::Filter;
|
use self::{
|
||||||
|
ffmpeg::{FfmpegOutput, FfmpegOutputFormat},
|
||||||
|
filter::Filter
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
iotro::{intro, outro},
|
iotro::{intro, outro},
|
||||||
|
project::{Project, ProjectLecture, ProjectSourceMetadata, Resolution},
|
||||||
|
question::Question,
|
||||||
render::ffmpeg::{Ffmpeg, FfmpegInput},
|
render::ffmpeg::{Ffmpeg, FfmpegInput},
|
||||||
time::{format_date, Time},
|
time::{format_date, format_time, Time}
|
||||||
Project, ProjectSourceMetadata, Resolution, MEM_LIMIT
|
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||||
use rational::Rational;
|
use console::style;
|
||||||
|
use ffmpeg::FfmpegOutputQuality;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
|
@ -33,24 +36,34 @@ const TRANSITION_LEN: Time = Time {
|
||||||
seconds: 0,
|
seconds: 0,
|
||||||
micros: 200_000
|
micros: 200_000
|
||||||
};
|
};
|
||||||
|
const QUESTION_FADE_LEN: Time = Time {
|
||||||
|
seconds: 0,
|
||||||
|
micros: 400_000
|
||||||
|
};
|
||||||
const FF_MULTIPLIER: usize = 8;
|
const FF_MULTIPLIER: usize = 8;
|
||||||
// logo sizes at full hd, will be scaled to source resolution
|
// logo sizes at full hd, will be scaled to source resolution
|
||||||
const FF_LOGO_SIZE: usize = 128;
|
const FF_LOGO_SIZE: u32 = 128;
|
||||||
const LOGO_SIZE: usize = 96;
|
const LOGO_SIZE: u32 = 96;
|
||||||
|
|
||||||
fn cmd() -> Command {
|
fn cmd() -> Command {
|
||||||
// we use systemd-run to limit the process memory
|
#[cfg(feature = "mem_limit")]
|
||||||
// I tried others like ulimit, chpst or isolate, but none worked
|
let mut cmd = {
|
||||||
let mut cmd = Command::new("systemd-run");
|
// we use systemd-run to limit the process memory
|
||||||
cmd.arg("--scope")
|
// I tried others like ulimit, chpst or isolate, but none worked
|
||||||
.arg("-q")
|
let mut cmd = Command::new("systemd-run");
|
||||||
.arg("--expand-environment=no")
|
cmd.arg("--scope")
|
||||||
.arg("-p")
|
.arg("-q")
|
||||||
.arg(format!("MemoryMax={}", MEM_LIMIT.read().unwrap()))
|
.arg("--expand-environment=no")
|
||||||
.arg("--user");
|
.arg("-p")
|
||||||
// we use busybox ash for having a shell that outputs commands with -x
|
.arg(format!("MemoryMax={}", crate::MEM_LIMIT.read().unwrap()))
|
||||||
cmd.arg("busybox")
|
.arg("--user");
|
||||||
.arg("ash")
|
// we use busybox ash for having a shell that outputs commands with -x
|
||||||
|
cmd.arg("busybox");
|
||||||
|
cmd
|
||||||
|
};
|
||||||
|
#[cfg(not(feature = "mem_limit"))]
|
||||||
|
let mut cmd = Command::new("busybox");
|
||||||
|
cmd.arg("ash")
|
||||||
.arg("-exuo")
|
.arg("-exuo")
|
||||||
.arg("pipefail")
|
.arg("pipefail")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
|
@ -107,20 +120,35 @@ fn ffprobe_audio(query: &str, concat_input: &Path) -> anyhow::Result<String> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Renderer<'a> {
|
pub struct Renderer<'a> {
|
||||||
/// The directory with all the sources.
|
/// The directory with all the sources.
|
||||||
directory: &'a Path,
|
directory: &'a Path,
|
||||||
|
|
||||||
/// The slug (i.e. 23ws-malo2-231016).
|
/// The slug (i.e. 23ws-malo2-231016).
|
||||||
slug: String,
|
slug: String,
|
||||||
/// The target directory.
|
/// The target directory.
|
||||||
target: PathBuf
|
target: PathBuf,
|
||||||
|
/// The format to use for intermediate products
|
||||||
|
format: FfmpegOutputFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
fn svg2mp4(svg: PathBuf, mp4: PathBuf, duration: Time) -> anyhow::Result<()> {
|
fn svg2mkv(
|
||||||
let mut ffmpeg = Ffmpeg::new(mp4);
|
meta: &ProjectSourceMetadata,
|
||||||
|
svg: PathBuf,
|
||||||
|
mkv: PathBuf,
|
||||||
|
format: FfmpegOutputFormat,
|
||||||
|
duration: Time
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
|
||||||
|
quality: FfmpegOutputQuality::VisuallyLossless,
|
||||||
|
duration: Some(duration),
|
||||||
|
time_base: Some(meta.source_tbn),
|
||||||
|
fps_mode_vfr: true,
|
||||||
|
..FfmpegOutput::new(format, mkv)
|
||||||
|
});
|
||||||
ffmpeg.add_input(FfmpegInput {
|
ffmpeg.add_input(FfmpegInput {
|
||||||
loop_input: true,
|
loop_input: true,
|
||||||
|
fps: Some(meta.source_fps),
|
||||||
..FfmpegInput::new(svg)
|
..FfmpegInput::new(svg)
|
||||||
});
|
});
|
||||||
ffmpeg.add_filter(Filter::GenerateSilence {
|
ffmpeg.add_filter(Filter::GenerateSilence {
|
||||||
|
@ -128,18 +156,16 @@ fn svg2mp4(svg: PathBuf, mp4: PathBuf, duration: Time) -> anyhow::Result<()> {
|
||||||
output: "out".into()
|
output: "out".into()
|
||||||
});
|
});
|
||||||
ffmpeg.set_filter_output("out");
|
ffmpeg.set_filter_output("out");
|
||||||
ffmpeg.set_duration(duration);
|
|
||||||
ffmpeg.run()
|
ffmpeg.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn svg2png(svg: &Path, png: &Path, size: usize) -> anyhow::Result<()> {
|
fn svg2png(svg: &Path, png: &Path, width: u32, height: u32) -> anyhow::Result<()> {
|
||||||
let mut cmd = cmd();
|
let mut cmd = cmd();
|
||||||
let size = size.to_string();
|
|
||||||
cmd.arg("inkscape")
|
cmd.arg("inkscape")
|
||||||
.arg("-w")
|
.arg("-w")
|
||||||
.arg(&size)
|
.arg(width.to_string())
|
||||||
.arg("-h")
|
.arg("-h")
|
||||||
.arg(&size);
|
.arg(height.to_string());
|
||||||
cmd.arg(svg).arg("-o").arg(png);
|
cmd.arg(svg).arg("-o").arg(png);
|
||||||
|
|
||||||
let status = cmd.status()?;
|
let status = cmd.status()?;
|
||||||
|
@ -151,7 +177,7 @@ fn svg2png(svg: &Path, png: &Path, size: usize) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Renderer<'a> {
|
impl<'a> Renderer<'a> {
|
||||||
pub(crate) fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
|
pub fn new(directory: &'a Path, project: &Project) -> anyhow::Result<Self> {
|
||||||
let slug = format!(
|
let slug = format!(
|
||||||
"{}-{}",
|
"{}-{}",
|
||||||
project.lecture.course,
|
project.lecture.course,
|
||||||
|
@ -160,78 +186,128 @@ impl<'a> Renderer<'a> {
|
||||||
let target = directory.join(&slug);
|
let target = directory.join(&slug);
|
||||||
fs::create_dir_all(&target)?;
|
fs::create_dir_all(&target)?;
|
||||||
|
|
||||||
|
// Ensure we have at least one input file.
|
||||||
|
project
|
||||||
|
.source
|
||||||
|
.files
|
||||||
|
.first()
|
||||||
|
.context("No source files present")?;
|
||||||
|
|
||||||
|
// In case we don't have a resolution yet, we'll asign this after preprocessing.
|
||||||
|
let format = project
|
||||||
|
.source
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|meta| meta.source_res.default_codec())
|
||||||
|
.unwrap_or(FfmpegOutputFormat::Av1Flac)
|
||||||
|
.with_flac_audio();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
directory,
|
directory,
|
||||||
slug,
|
slug,
|
||||||
target
|
target,
|
||||||
|
format
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn recording_mp4(&self) -> PathBuf {
|
pub fn recording_mkv(&self) -> PathBuf {
|
||||||
self.target.join("recording.mp4")
|
self.target.join("recording.mkv")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn preprocess(&self, project: &mut Project) -> anyhow::Result<()> {
|
fn intro_mkv(&self) -> PathBuf {
|
||||||
|
self.target.join("intro.mkv")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outro_mkv(&self) -> PathBuf {
|
||||||
|
self.target.join("outro.mkv")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn question_svg(&self, q_idx: usize) -> PathBuf {
|
||||||
|
self.target.join(format!("question{q_idx}.svg"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn question_png(&self, q_idx: usize) -> PathBuf {
|
||||||
|
self.target.join(format!("question{q_idx}.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preprocess(&mut self, project: &mut Project) -> anyhow::Result<()> {
|
||||||
assert!(!project.progress.preprocessed);
|
assert!(!project.progress.preprocessed);
|
||||||
|
|
||||||
let recording_txt = self.target.join("recording.txt");
|
let recording_txt = self.target.join("recording.txt");
|
||||||
let mut file = File::create(&recording_txt)?;
|
let mut file = File::create(&recording_txt)?;
|
||||||
for filename in &project.source.files {
|
for filename in &project.source.files {
|
||||||
writeln!(file, "file '{}'", self.directory.join(filename).to_string());
|
writeln!(file, "file '{}'", self.directory.join(filename))?;
|
||||||
}
|
}
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
println!("\x1B[1m ==> Concatenating Video and Normalising Audio ...\x1B[0m");
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("==>").bold().cyan(),
|
||||||
|
style("Concatenating Video and Normalising Audio ...").bold()
|
||||||
|
);
|
||||||
|
|
||||||
let source_sample_rate =
|
let source_sample_rate =
|
||||||
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
||||||
let recording_mp4 = self.recording_mp4();
|
let recording_mkv = self.recording_mkv();
|
||||||
let mut ffmpeg = Ffmpeg::new(recording_mp4.clone());
|
let mut ffmpeg = Ffmpeg::new(FfmpegOutput::new(
|
||||||
|
FfmpegOutputFormat::Av1Flac,
|
||||||
|
recording_mkv.clone()
|
||||||
|
));
|
||||||
ffmpeg.add_input(FfmpegInput {
|
ffmpeg.add_input(FfmpegInput {
|
||||||
concat: true,
|
concat: true,
|
||||||
..FfmpegInput::new(recording_txt)
|
..FfmpegInput::new(recording_txt)
|
||||||
});
|
});
|
||||||
ffmpeg.enable_loudnorm();
|
ffmpeg.enable_loudnorm(project.source.stereo);
|
||||||
ffmpeg.run()?;
|
ffmpeg.run()?;
|
||||||
|
|
||||||
let width = ffprobe_video("stream=width", &recording_mp4)?.parse()?;
|
let width = ffprobe_video("stream=width", &recording_mkv)?.parse()?;
|
||||||
let height = ffprobe_video("stream=height", &recording_mp4)?.parse()?;
|
let height = ffprobe_video("stream=height", &recording_mkv)?.parse()?;
|
||||||
let source_res = match (width, height) {
|
let source_res = Resolution::new(width, height);
|
||||||
(3840, 2160) => Resolution::UHD,
|
|
||||||
(2560, 1440) => Resolution::WQHD,
|
|
||||||
(1920, 1080) => Resolution::FullHD,
|
|
||||||
(1280, 720) => Resolution::HD,
|
|
||||||
(640, 360) => Resolution::nHD,
|
|
||||||
(width, height) => bail!("Unknown resolution: {width}x{height}")
|
|
||||||
};
|
|
||||||
project.source.metadata = Some(ProjectSourceMetadata {
|
project.source.metadata = Some(ProjectSourceMetadata {
|
||||||
source_duration: ffprobe_video("format=duration", &recording_mp4)?.parse()?,
|
source_duration: ffprobe_video("format=duration", &recording_mkv)?.parse()?,
|
||||||
source_fps: ffprobe_video("stream=r_frame_rate", &recording_mp4)?.parse()?,
|
source_fps: ffprobe_video("stream=r_frame_rate", &recording_mkv)?.parse()?,
|
||||||
source_tbn: ffprobe_video("stream=time_base", &recording_mp4)?.parse()?,
|
source_tbn: ffprobe_video("stream=time_base", &recording_mkv)?.parse()?,
|
||||||
source_res,
|
source_res,
|
||||||
source_sample_rate
|
source_sample_rate
|
||||||
});
|
});
|
||||||
|
self.format = source_res.default_codec().with_flac_audio();
|
||||||
|
|
||||||
println!("\x1B[1m ==> Preparing assets ...\x1B[0m");
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare assets like intro, outro and questions.
|
||||||
|
pub fn render_assets(&self, project: &Project) -> anyhow::Result<()> {
|
||||||
|
let metadata = project.source.metadata.as_ref().unwrap();
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("==>").bold().cyan(),
|
||||||
|
style("Preparing assets ...").bold()
|
||||||
|
);
|
||||||
|
|
||||||
// render intro to svg then mp4
|
// render intro to svg then mp4
|
||||||
let intro_svg = self.target.join("intro.svg");
|
let intro_svg = self.target.join("intro.svg");
|
||||||
fs::write(
|
fs::write(
|
||||||
&intro_svg,
|
&intro_svg,
|
||||||
intro(source_res, project.lecture.date)
|
intro(metadata.source_res, &project.lecture)
|
||||||
.to_string_pretty()
|
.to_string_pretty()
|
||||||
.into_bytes()
|
.into_bytes()
|
||||||
)?;
|
)?;
|
||||||
let intro_mp4 = self.target.join("intro.mp4");
|
let intro_mkv = self.intro_mkv();
|
||||||
svg2mp4(intro_svg, intro_mp4, INTRO_LEN)?;
|
svg2mkv(metadata, intro_svg, intro_mkv, self.format, INTRO_LEN)?;
|
||||||
|
|
||||||
// render outro to svg then mp4
|
// render outro to svg then mp4
|
||||||
let outro_svg = self.target.join("outro.svg");
|
let outro_svg = self.target.join("outro.svg");
|
||||||
fs::write(
|
fs::write(
|
||||||
&outro_svg,
|
&outro_svg,
|
||||||
outro(source_res).to_string_pretty().into_bytes()
|
outro(&project.lecture.lang, metadata.source_res)
|
||||||
|
.to_string_pretty()
|
||||||
|
.into_bytes()
|
||||||
)?;
|
)?;
|
||||||
let outro_mp4 = self.target.join("outro.mp4");
|
let outro_mkv = self.outro_mkv();
|
||||||
svg2mp4(outro_svg, outro_mp4, OUTRO_LEN)?;
|
svg2mkv(metadata, outro_svg, outro_mkv, self.format, OUTRO_LEN)?;
|
||||||
|
|
||||||
// copy logo then render to png
|
// copy logo then render to png
|
||||||
let logo_svg = self.target.join("logo.svg");
|
let logo_svg = self.target.join("logo.svg");
|
||||||
|
@ -240,7 +316,8 @@ impl<'a> Renderer<'a> {
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/logo.svg"))
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/logo.svg"))
|
||||||
)?;
|
)?;
|
||||||
let logo_png = self.target.join("logo.png");
|
let logo_png = self.target.join("logo.png");
|
||||||
svg2png(&logo_svg, &logo_png, LOGO_SIZE * source_res.width() / 1920)?;
|
let logo_size = LOGO_SIZE * metadata.source_res.width() / 1920;
|
||||||
|
svg2png(&logo_svg, &logo_png, logo_size, logo_size)?;
|
||||||
|
|
||||||
// copy fastforward then render to png
|
// copy fastforward then render to png
|
||||||
let fastforward_svg = self.target.join("fastforward.svg");
|
let fastforward_svg = self.target.join("fastforward.svg");
|
||||||
|
@ -252,33 +329,64 @@ impl<'a> Renderer<'a> {
|
||||||
))
|
))
|
||||||
)?;
|
)?;
|
||||||
let fastforward_png = self.target.join("fastforward.png");
|
let fastforward_png = self.target.join("fastforward.png");
|
||||||
|
let ff_logo_size = FF_LOGO_SIZE * metadata.source_res.width() / 1920;
|
||||||
svg2png(
|
svg2png(
|
||||||
&fastforward_svg,
|
&fastforward_svg,
|
||||||
&fastforward_png,
|
&fastforward_png,
|
||||||
FF_LOGO_SIZE * source_res.width() / 1920
|
ff_logo_size,
|
||||||
|
ff_logo_size
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// write questions then render to png
|
||||||
|
for (q_idx, (_, _, q_text)) in project.source.questions.iter().enumerate() {
|
||||||
|
let q = Question::new(metadata.source_res, &project.lecture.lang, q_text)
|
||||||
|
.finish()
|
||||||
|
.to_string_pretty()
|
||||||
|
.into_bytes();
|
||||||
|
let q_svg = self.question_svg(q_idx);
|
||||||
|
let q_png = self.question_png(q_idx);
|
||||||
|
fs::write(&q_svg, q)?;
|
||||||
|
svg2png(
|
||||||
|
&q_svg,
|
||||||
|
&q_png,
|
||||||
|
metadata.source_res.width(),
|
||||||
|
metadata.source_res.height()
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render(&self, project: &mut Project) -> anyhow::Result<PathBuf> {
|
/// Get the video file for a specific resolution, completely finished.
|
||||||
let mut output = self.target.join(format!(
|
fn video_file_res(&self, res: Resolution) -> PathBuf {
|
||||||
"{}-{}p.mp4",
|
let extension = match res.default_codec() {
|
||||||
self.slug,
|
FfmpegOutputFormat::Av1Flac | FfmpegOutputFormat::AvcFlac => "mkv",
|
||||||
project
|
FfmpegOutputFormat::Av1Opus => "webm",
|
||||||
.source
|
FfmpegOutputFormat::AvcAac => "mp4"
|
||||||
.metadata
|
};
|
||||||
.as_ref()
|
self.target
|
||||||
.unwrap()
|
.join(format!("{}-{}p.{extension}", self.slug, res.height()))
|
||||||
.source_res
|
}
|
||||||
.height()
|
|
||||||
));
|
/// Get the video file directly outputed to further transcode.
|
||||||
let mut ffmpeg = Ffmpeg::new(output.clone());
|
pub fn video_file_output(&self) -> PathBuf {
|
||||||
|
self.target.join(format!("{}.mkv", self.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self, project: &mut Project) -> anyhow::Result<PathBuf> {
|
||||||
|
let source_res = project.source.metadata.as_ref().unwrap().source_res;
|
||||||
|
|
||||||
|
let output = self.video_file_output();
|
||||||
|
let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
|
||||||
|
quality: FfmpegOutputQuality::VisuallyLossless,
|
||||||
|
video_bitrate: Some(source_res.bitrate() * 3),
|
||||||
|
..FfmpegOutput::new(self.format, output.clone())
|
||||||
|
});
|
||||||
|
|
||||||
// add all of our inputs
|
// add all of our inputs
|
||||||
let intro = ffmpeg.add_input(FfmpegInput::new(self.target.join("intro.mp4")));
|
let intro = ffmpeg.add_input(FfmpegInput::new(self.intro_mkv()));
|
||||||
let rec_file = self.target.join("recording.mp4");
|
let rec_file = self.recording_mkv();
|
||||||
let outro = ffmpeg.add_input(FfmpegInput::new(self.target.join("outro.mp4")));
|
let outro = ffmpeg.add_input(FfmpegInput::new(self.outro_mkv()));
|
||||||
let logo = ffmpeg.add_input(FfmpegInput::new(self.target.join("logo.png")));
|
let logo = ffmpeg.add_input(FfmpegInput::new(self.target.join("logo.png")));
|
||||||
let ff = ffmpeg.add_input(FfmpegInput::new(self.target.join("fastforward.png")));
|
let ff = ffmpeg.add_input(FfmpegInput::new(self.target.join("fastforward.png")));
|
||||||
|
|
||||||
|
@ -286,21 +394,24 @@ impl<'a> Renderer<'a> {
|
||||||
let mut part3: Cow<'static, str> = outro.into();
|
let mut part3: Cow<'static, str> = outro.into();
|
||||||
|
|
||||||
// the recording is fun because of all the fast forwarding
|
// the recording is fun because of all the fast forwarding
|
||||||
let mut part2 = VecDeque::new();
|
let mut part2 = VecDeque::<Cow<'static, str>>::new();
|
||||||
|
let mut part2_ts = VecDeque::new();
|
||||||
let mut part2_start_of_the_end = None;
|
let mut part2_start_of_the_end = None;
|
||||||
let mut part2_end_of_the_start = None;
|
let mut part2_end_of_the_start = None;
|
||||||
|
|
||||||
// ok so ff is fun. we will add the ff'ed section as well as the part between
|
// ok so ff is fun. we will add the ff'ed section as well as the part between
|
||||||
// the previous ff'ed section and our new section, unless we are the first
|
// the previous ff'ed section and our new section, unless we are the first.
|
||||||
project.source.fast.sort();
|
project.source.fast.sort();
|
||||||
for (i, (ff_st, ff_end)) in project.source.fast.iter().rev().enumerate() {
|
for (i, (ff_st, ff_end)) in project.source.fast.iter().rev().enumerate() {
|
||||||
if let Some(prev_end) = part2_end_of_the_start {
|
if let Some(prev_end) = part2_end_of_the_start {
|
||||||
|
let duration = prev_end - *ff_end;
|
||||||
let recffbetween = ffmpeg.add_input(FfmpegInput {
|
let recffbetween = ffmpeg.add_input(FfmpegInput {
|
||||||
start: Some(*ff_end),
|
start: Some(*ff_end),
|
||||||
duration: Some(prev_end - *ff_end),
|
duration: Some(duration),
|
||||||
..FfmpegInput::new(rec_file.clone())
|
..FfmpegInput::new(rec_file.clone())
|
||||||
});
|
});
|
||||||
part2.push_front(recffbetween.into());
|
part2.push_front(recffbetween.into());
|
||||||
|
part2_ts.push_front(Some((*ff_end, duration)));
|
||||||
} else {
|
} else {
|
||||||
part2_start_of_the_end = Some(*ff_end);
|
part2_start_of_the_end = Some(*ff_end);
|
||||||
}
|
}
|
||||||
|
@ -320,6 +431,7 @@ impl<'a> Renderer<'a> {
|
||||||
output: recff.clone().into()
|
output: recff.clone().into()
|
||||||
});
|
});
|
||||||
part2.push_front(recff.into());
|
part2.push_front(recff.into());
|
||||||
|
part2_ts.push_front(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the recording was not ff'ed, perform a normal trim
|
// if the recording was not ff'ed, perform a normal trim
|
||||||
|
@ -334,23 +446,112 @@ impl<'a> Renderer<'a> {
|
||||||
..FfmpegInput::new(rec_file.clone())
|
..FfmpegInput::new(rec_file.clone())
|
||||||
});
|
});
|
||||||
part2.push_back(rectrim.into());
|
part2.push_back(rectrim.into());
|
||||||
|
part2_ts.push_back(Some((start, part2_last_part_duration)));
|
||||||
}
|
}
|
||||||
// otherwise add the first and last parts separately
|
// otherwise add the first and last parts separately
|
||||||
else {
|
else {
|
||||||
|
let duration = part2_end_of_the_start.unwrap() - start;
|
||||||
let rectrimst = ffmpeg.add_input(FfmpegInput {
|
let rectrimst = ffmpeg.add_input(FfmpegInput {
|
||||||
start: Some(start),
|
start: Some(start),
|
||||||
duration: Some(part2_end_of_the_start.unwrap() - start),
|
duration: Some(duration),
|
||||||
..FfmpegInput::new(rec_file.clone())
|
..FfmpegInput::new(rec_file.clone())
|
||||||
});
|
});
|
||||||
part2.push_front(rectrimst.into());
|
part2.push_front(rectrimst.into());
|
||||||
|
part2_ts.push_front(Some((start, duration)));
|
||||||
|
|
||||||
part2_last_part_duration = end - part2_start_of_the_end.unwrap();
|
let part2_start_of_the_end = part2_start_of_the_end.unwrap();
|
||||||
|
part2_last_part_duration = end - part2_start_of_the_end;
|
||||||
let rectrimend = ffmpeg.add_input(FfmpegInput {
|
let rectrimend = ffmpeg.add_input(FfmpegInput {
|
||||||
start: Some(part2_start_of_the_end.unwrap()),
|
start: Some(part2_start_of_the_end),
|
||||||
duration: Some(part2_last_part_duration),
|
duration: Some(part2_last_part_duration),
|
||||||
..FfmpegInput::new(rec_file.clone())
|
..FfmpegInput::new(rec_file.clone())
|
||||||
});
|
});
|
||||||
part2.push_back(rectrimend.into());
|
part2.push_back(rectrimend.into());
|
||||||
|
part2_ts.push_back(Some((part2_start_of_the_end, part2_last_part_duration)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok now we have a bunch of parts and a bunch of questions that want to get
|
||||||
|
// overlayed over those parts.
|
||||||
|
project.source.questions.sort();
|
||||||
|
let mut q_idx = 0;
|
||||||
|
for (i, ts) in part2_ts.iter().enumerate() {
|
||||||
|
let Some((start, duration)) = ts else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
if q_idx >= project.source.questions.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (q_start, q_end, _) = &project.source.questions[q_idx];
|
||||||
|
if q_start < start {
|
||||||
|
bail!(
|
||||||
|
"Question starting at {} did not fit into the video",
|
||||||
|
format_time(*q_start)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if q_start >= start && *q_end <= *start + *duration {
|
||||||
|
// add the question as input to ffmpeg
|
||||||
|
let q_inp = ffmpeg.add_input(FfmpegInput {
|
||||||
|
loop_input: true,
|
||||||
|
fps: Some(project.source.metadata.as_ref().unwrap().source_fps),
|
||||||
|
duration: Some(*q_end - *q_start),
|
||||||
|
..FfmpegInput::new(self.question_png(q_idx))
|
||||||
|
});
|
||||||
|
|
||||||
|
// fade in the question
|
||||||
|
let q_fadein = format!("q{q_idx}fin");
|
||||||
|
ffmpeg.add_filter(Filter::FadeAlpha {
|
||||||
|
input: q_inp.into(),
|
||||||
|
direction: "in",
|
||||||
|
start: Time {
|
||||||
|
seconds: 0,
|
||||||
|
micros: 0
|
||||||
|
},
|
||||||
|
duration: QUESTION_FADE_LEN,
|
||||||
|
output: q_fadein.clone().into()
|
||||||
|
});
|
||||||
|
|
||||||
|
// fade out the question
|
||||||
|
let q_fadeout = format!("q{q_idx}fout");
|
||||||
|
ffmpeg.add_filter(Filter::FadeAlpha {
|
||||||
|
input: q_fadein.into(),
|
||||||
|
direction: "out",
|
||||||
|
start: *q_end - *q_start - QUESTION_FADE_LEN,
|
||||||
|
duration: QUESTION_FADE_LEN,
|
||||||
|
output: q_fadeout.clone().into()
|
||||||
|
});
|
||||||
|
|
||||||
|
// move the question to the correct timestamp
|
||||||
|
let q_pts = format!("q{q_idx}pts");
|
||||||
|
ffmpeg.add_filter(Filter::VideoOffset {
|
||||||
|
input: q_fadeout.into(),
|
||||||
|
seconds: *q_start - *start,
|
||||||
|
output: q_pts.clone().into()
|
||||||
|
});
|
||||||
|
|
||||||
|
// overlay the part in question
|
||||||
|
let q_overlay = format!("q{q_idx}o");
|
||||||
|
ffmpeg.add_filter(Filter::Overlay {
|
||||||
|
video_input: part2[i].clone(),
|
||||||
|
overlay_input: q_pts.into(),
|
||||||
|
x: "0".into(),
|
||||||
|
y: "0".into(),
|
||||||
|
repeatlast: false,
|
||||||
|
output: q_overlay.clone().into()
|
||||||
|
});
|
||||||
|
part2[i] = q_overlay.into();
|
||||||
|
|
||||||
|
q_idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q_idx < project.source.questions.len() {
|
||||||
|
bail!(
|
||||||
|
"Question starting at {} did not fit into the video before it was over",
|
||||||
|
format_time(project.source.questions[q_idx].0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// fade out the intro
|
// fade out the intro
|
||||||
|
@ -414,12 +615,21 @@ impl<'a> Renderer<'a> {
|
||||||
});
|
});
|
||||||
|
|
||||||
// overlay the logo
|
// overlay the logo
|
||||||
|
let logoalpha = "logoalpha";
|
||||||
|
ffmpeg.add_filter(Filter::Alpha {
|
||||||
|
input: logo.into(),
|
||||||
|
alpha: 0.5,
|
||||||
|
output: logoalpha.into()
|
||||||
|
});
|
||||||
let overlay = "overlay";
|
let overlay = "overlay";
|
||||||
|
let overlay_off_x = 130 * source_res.width() / 3840;
|
||||||
|
let overlay_off_y = 65 * source_res.height() / 2160;
|
||||||
ffmpeg.add_filter(Filter::Overlay {
|
ffmpeg.add_filter(Filter::Overlay {
|
||||||
video_input: concat.into(),
|
video_input: concat.into(),
|
||||||
overlay_input: logo.into(),
|
overlay_input: logoalpha.into(),
|
||||||
x: "main_w-overlay_w-130".into(),
|
x: format!("main_w-overlay_w-{overlay_off_x}").into(),
|
||||||
y: "main_h-overlay_h-65".into(),
|
y: format!("main_h-overlay_h-{overlay_off_y}").into(),
|
||||||
|
repeatlast: true,
|
||||||
output: overlay.into()
|
output: overlay.into()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -429,4 +639,42 @@ impl<'a> Renderer<'a> {
|
||||||
|
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rescale(
|
||||||
|
&self,
|
||||||
|
lecture: &ProjectLecture,
|
||||||
|
res: Resolution
|
||||||
|
) -> anyhow::Result<PathBuf> {
|
||||||
|
let input = self.video_file_output();
|
||||||
|
let output = self.video_file_res(res);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
style("==>").bold().cyan(),
|
||||||
|
style(format!("Rescaling to {}p", res.height())).bold()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
|
||||||
|
video_bitrate: Some(res.bitrate()),
|
||||||
|
|
||||||
|
title: Some(format!(
|
||||||
|
"{} {} {}",
|
||||||
|
lecture.label,
|
||||||
|
lecture.lang.from,
|
||||||
|
(lecture.lang.format_date_long)(lecture.date)
|
||||||
|
)),
|
||||||
|
author: Some(lecture.docent.clone()),
|
||||||
|
album: Some(lecture.course.clone()),
|
||||||
|
year: Some(lecture.date.year.to_string()),
|
||||||
|
comment: Some(lecture.lang.video_created_by_us.into()),
|
||||||
|
language: Some(lecture.lang.lang.into()),
|
||||||
|
|
||||||
|
..FfmpegOutput::new(res.default_codec(), output.clone()).enable_faststart()
|
||||||
|
});
|
||||||
|
ffmpeg.add_input(FfmpegInput::new(input));
|
||||||
|
ffmpeg.rescale_video(res);
|
||||||
|
ffmpeg.run()?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
26
src/time.rs
26
src/time.rs
|
@ -57,26 +57,6 @@ pub fn format_date(d: Date) -> String {
|
||||||
format!("{:02}{:02}{:02}", d.year % 100, d.month, d.day)
|
format!("{:02}{:02}{:02}", d.year % 100, d.month, d.day)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a date in DD. MMMM YYYY format.
|
|
||||||
pub fn format_date_long(d: Date) -> String {
|
|
||||||
let month = match d.month {
|
|
||||||
1 => "Januar",
|
|
||||||
2 => "Februar",
|
|
||||||
3 => "März",
|
|
||||||
4 => "April",
|
|
||||||
5 => "Mai",
|
|
||||||
6 => "Juni",
|
|
||||||
7 => "Juli",
|
|
||||||
8 => "August",
|
|
||||||
9 => "September",
|
|
||||||
10 => "Oktober",
|
|
||||||
11 => "November",
|
|
||||||
12 => "Dezember",
|
|
||||||
_ => unreachable!()
|
|
||||||
};
|
|
||||||
format!("{:02}. {month} {:04}", d.day, d.year)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
|
||||||
pub struct Time {
|
pub struct Time {
|
||||||
pub seconds: u32,
|
pub seconds: u32,
|
||||||
|
@ -147,7 +127,7 @@ pub fn parse_time(s: &str) -> anyhow::Result<Time> {
|
||||||
}
|
}
|
||||||
// the 4th split is subseconds, converting to micros
|
// the 4th split is subseconds, converting to micros
|
||||||
if i == 3 {
|
if i == 3 {
|
||||||
micros = digits.parse::<u32>()? * 1000000 / 60;
|
micros = digits.parse::<u32>()? * 1_000_000 / 60;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// add to seconds and potentially micros
|
// add to seconds and potentially micros
|
||||||
|
@ -230,7 +210,7 @@ mod tests {
|
||||||
fn test_time_subsecs() {
|
fn test_time_subsecs() {
|
||||||
test_time_parse_only("1:02:03:30", Time {
|
test_time_parse_only("1:02:03:30", Time {
|
||||||
seconds: 3723,
|
seconds: 3723,
|
||||||
micros: 500000
|
micros: 500_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -238,7 +218,7 @@ mod tests {
|
||||||
fn test_time_micros() {
|
fn test_time_micros() {
|
||||||
test_time("1:02:03.5", Time {
|
test_time("1:02:03.5", Time {
|
||||||
seconds: 3723,
|
seconds: 3723,
|
||||||
micros: 500000
|
micros: 500_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue