Compare commits

...

75 commits
231031 ... main

Author SHA1 Message Date
d5bb7a4bdc
only use vaapi when feature is enabled
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 4s
2024-07-08 13:18:26 +02:00
5e1f5e8829
add readme
[skip ci] no need
2024-06-29 11:21:06 +02:00
5808bff395
fix still defaulting to av1, allow aac to be paired with flac
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 4s
2024-06-28 18:19:08 +02:00
330515d6b4
fix toml serialiser being stupid
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-28 16:56:07 +02:00
7662150b89
fix codec comparison; encode with higher quality for intermediate results
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-26 12:05:40 +02:00
b6fb0fa184
uncommitted stuff: some bitrate changes [skip ci] 2024-06-23 23:59:23 +02:00
6e56452f78
limit webhook ci to main branch
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-23 17:54:18 +02:00
680ea8f4e5
Refactor the code into a binary and library (#1)
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
Reviewed-on: #1
Co-authored-by: Dominic <git@msrd0.de>
Co-committed-by: Dominic <git@msrd0.de>
2024-06-23 15:53:45 +00:00
13c03559d0
set svt-av1 preset to 7 and quality to 28
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-06-13 15:53:40 +02:00
f9129b2351
we are using av1 for 1080p
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-06-04 09:51:45 +02:00
9a58e39bf8
fix typo, thanks Dorian
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 7s
This code path is currently unused given that we accumulate the input
into a recording.mkv file that doesn't have the cursed mp4/mov behaviour.
2024-06-04 09:47:58 +02:00
2fdb653496
seek_streams_individually can also happen with .mov files
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-05-30 11:13:12 +02:00
cbdf55335a
detect .mov files
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-05-27 20:05:41 +02:00
6934012c11
take audio from left instead of right channel
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 6s
2024-05-27 20:05:07 +02:00
52c89dc95a
fix british dates
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-05-26 16:15:36 +02:00
4aefb5a647
presets 2024-05-26 16:15:07 +02:00
14daa1c9f9
add missing file 2024-05-26 15:23:31 +02:00
78609dec9a
translate question 2024-05-26 15:23:23 +02:00
1dfe835587
move project structs into their own mod 2024-05-26 15:20:40 +02:00
b6bd1be12e
docker: don't copy Cargo.lock; it's not part of the repo
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-05-26 12:47:25 +02:00
4f4711cf31
CI: fix #5
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-05-26 12:37:56 +02:00
e55886a0df
CI: fix #4
Some checks failed
Trigger quay.io Webhook / run (push) Failing after 5s
2024-05-26 12:34:48 +02:00
867544f12e
CI: fix #3
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-05-26 12:29:51 +02:00
2ba0f6a075
CI: apparently forgejo prefers github vendor prefix
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 5s
2024-05-26 12:21:54 +02:00
17d961bc0d
CI: fix #2
All checks were successful
Trigger quay.io Webhook / run (push) Successful in 4s
2024-05-26 12:20:07 +02:00
be787b6c9c
CI: fix
Some checks failed
Trigger quay.io Webhook / run (push) Failing after 5s
2024-05-26 12:19:22 +02:00
fad24597fd
CI: add webhook triggering quay.io 2024-05-26 12:18:17 +02:00
8147ffd231
update alpine to 3.20 2024-05-26 11:47:53 +02:00
5b2d6653dc
add dockerignore 2024-05-26 11:33:42 +02:00
97a7268d4a
fix docker not compiling 2024-05-26 11:33:25 +02:00
677c35a6fd
remove test files 2024-05-25 16:21:04 +02:00
b11baf1358
question overlays work :) 2024-05-25 16:20:43 +02:00
8b57b97c80
add asked questions to project.toml 2024-05-25 14:25:23 +02:00
1e7f5f95cd
clean up console output a little 2024-05-25 14:17:07 +02:00
01e0758b6a
build question overlay svg 2024-05-25 13:53:15 +02:00
7b1681d85d
create a fancy new fast forward logo based on our videoag logo 2024-05-24 19:58:52 +02:00
9ae95fefb6
find a tool that optimizes svg's even further 2024-05-24 19:40:08 +02:00
05b650dfd7
use readable logo 2024-05-24 12:39:43 +02:00
410a4eaf96
strip the logo of any useless crap and make it actually nice to read/edit 2024-05-24 12:39:00 +02:00
3798b3382a
start understanding the logo svg 2024-05-24 12:29:10 +02:00
6de519c980
create a readable version of the logo svg 2024-05-24 12:21:49 +02:00
98f415ade7
add metadata to transcoded videos 2024-05-24 12:17:40 +02:00
9a4b3142ff
optimize some libsvtav1 params 2024-05-24 12:01:45 +02:00
d323915aed
install fonts in docker 2024-05-20 17:34:08 +02:00
0de4f35311
start putting together a dockerfile
is still missing font installation plus potentially more stuff
2024-05-18 23:24:09 +02:00
cae7b9b99b
add default language 2024-05-18 15:21:14 +02:00
4612cbdfaa
allow compiling out systemd-run support 2024-05-18 12:52:08 +02:00
0d98ab21f0
use libsvtav1 instead of av1 when vaapi is disabled 2024-05-18 12:40:18 +02:00
9d87c61a39
render 1080p using av1 2024-05-17 22:57:17 +02:00
cd705c1153
language support 2024-05-17 21:29:22 +02:00
f5ec5db460
bump indexmap to 2.2 2024-04-10 13:05:55 +02:00
3c0169f6bb
add license 2024-04-10 13:01:04 +02:00
881ab99410
stop debug-printing the project.toml all the time 2024-04-10 12:56:03 +02:00
8df868eff3
support changing label/docent in intro slide 2024-04-10 12:55:00 +02:00
8e2b72c431
allow specifying the start resolution for transcoding 2024-01-16 00:24:12 +01:00
fead583ce9
only use av1 for >fhd videos 2024-01-15 18:50:15 +01:00
3aeb9dd8be
skip some irrelevant ffmpeg options 2024-01-09 22:01:35 +01:00
d7d30ac6bf
don't skip symlinks 2024-01-09 21:27:35 +01:00
5c50b33251
allow preprocessing to keep stereo; asks which files to use 2024-01-06 18:55:27 +01:00
f4adda912a
use AV1 for 1440p and higher 2023-12-19 23:56:04 +01:00
c288f55ed0
fix overlay offset on lower resolutions 2023-11-16 12:23:10 +01:00
27e986d53b
fix warnings 2023-11-16 12:12:17 +01:00
fad0d1ec6c
optimize if chain 2023-11-16 12:05:13 +01:00
083bcb07c2
fix incorrectly setting vaapi output when copying codec 2023-11-16 11:52:17 +01:00
5746939c06
fix intro/outro producing mono audio 2023-11-16 11:50:49 +01:00
27c7cb3c7d
render videos with 3x bitrate 2023-11-16 11:49:38 +01:00
f2f3f67d10
set quality level to 22
24 hides a lot of details "in the background", i.e. in the dark green areas of the blackboard
2023-11-16 10:09:29 +01:00
5cc91d712f
run all transcoding through the ffmpeg helper 2023-11-16 09:33:58 +01:00
2882fb286a
support 50fps videos; enable +faststart 2023-11-15 15:44:58 +01:00
0865076849
setup test with 50 fps 2023-11-15 14:14:18 +01:00
270233ca5c
add faststart flag 2023-11-15 14:13:57 +01:00
24ea4ebe07
make transcoding optional 2023-11-14 10:36:12 +01:00
371071ca47
transcode in reverse order 2023-11-14 10:30:17 +01:00
feb8596bfc
support rescaling 2023-11-03 10:02:30 +01:00
268c4b3af7
add missing alpha filter on the logo 2023-11-02 23:48:56 +01:00
21 changed files with 1962 additions and 642 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
**/target/
23*/
24*/

View 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
View 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 = []

View file

@ -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"
} }

View file

@ -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
View 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
View 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
View 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.

View file

@ -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

View file

@ -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
View 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()
}
}
}

View file

@ -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
View 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());

View file

@ -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
View 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
View 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
View 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
}
}

View file

@ -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() {

View file

@ -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)
]
}

View file

@ -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)
}
} }

View file

@ -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
}); });
} }
} }