Compare commits
3 commits
7b1681d85d
...
8b57b97c80
Author | SHA1 | Date | |
---|---|---|---|
8b57b97c80 | |||
1e7f5f95cd | |||
01e0758b6a |
7 changed files with 304 additions and 31 deletions
|
@ -10,7 +10,10 @@ license = "EPL-2.0"
|
|||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
camino = "1.1"
|
||||
console = "0.15"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
fontconfig = "0.8"
|
||||
harfbuzz_rs = "2.0"
|
||||
indexmap = "2.2"
|
||||
rational = "1.5"
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
|
|
BIN
question.png
Normal file
BIN
question.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 104 KiB |
|
@ -7,7 +7,7 @@ use std::{
|
|||
str::FromStr
|
||||
};
|
||||
use svgwriter::{
|
||||
tags::{Group, Rect, TagWithPresentationAttributes, Text},
|
||||
tags::{Group, Rect, TagWithPresentationAttributes as _, Text},
|
||||
Graphic
|
||||
};
|
||||
|
||||
|
@ -134,14 +134,14 @@ impl Debug for Language<'_> {
|
|||
}
|
||||
|
||||
#[repr(u16)]
|
||||
enum FontSize {
|
||||
pub(crate) enum FontSize {
|
||||
Huge = 72,
|
||||
Large = 56,
|
||||
Big = 44
|
||||
}
|
||||
|
||||
#[repr(u16)]
|
||||
enum FontWeight {
|
||||
pub(crate) enum FontWeight {
|
||||
Normal = 400,
|
||||
SemiBold = 500,
|
||||
Bold = 700
|
||||
|
|
127
src/main.rs
127
src/main.rs
|
@ -3,16 +3,19 @@
|
|||
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||
|
||||
mod iotro;
|
||||
mod question;
|
||||
mod render;
|
||||
mod time;
|
||||
|
||||
use crate::{
|
||||
use self::{
|
||||
iotro::Language,
|
||||
question::Question,
|
||||
render::{ffmpeg::FfmpegOutputFormat, Renderer},
|
||||
time::{parse_date, parse_time, Date, Time}
|
||||
};
|
||||
use camino::Utf8PathBuf as PathBuf;
|
||||
use clap::Parser;
|
||||
use iotro::Language;
|
||||
use console::style;
|
||||
use rational::Rational;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
@ -169,9 +172,15 @@ struct ProjectSource {
|
|||
start: Option<Time>,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
end: Option<Time>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
|
||||
fast: Vec<(Time, Time)>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr, _)>")]
|
||||
questions: Vec<(Time, Time, String)>,
|
||||
|
||||
metadata: Option<ProjectSourceMetadata>
|
||||
}
|
||||
|
||||
|
@ -195,10 +204,22 @@ struct ProjectSourceMetadata {
|
|||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
struct ProjectProgress {
|
||||
#[serde(default)]
|
||||
preprocessed: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_start_end: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_fast: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_questions: bool,
|
||||
|
||||
#[serde(default)]
|
||||
rendered: bool,
|
||||
|
||||
#[serde(default)]
|
||||
transcoded: BTreeSet<Resolution>
|
||||
}
|
||||
|
||||
|
@ -206,29 +227,44 @@ fn ask(question: impl Display) -> String {
|
|||
let mut stdout = io::stdout().lock();
|
||||
let mut stdin = io::stdin().lock();
|
||||
|
||||
writeln!(stdout, "{question}").unwrap();
|
||||
let mut line = String::new();
|
||||
write!(stdout, "> ").unwrap();
|
||||
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()
|
||||
}
|
||||
|
||||
fn ask_time(question: impl Display) -> Time {
|
||||
fn ask_time(question: impl Display + Copy) -> 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();
|
||||
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, "Invalid Input {line:?}: {err}").unwrap()
|
||||
Err(err) => writeln!(
|
||||
stdout,
|
||||
"{} {line:?}: {err}",
|
||||
style("Invalid Input").bold().red()
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -272,10 +308,14 @@ fn main() {
|
|||
|
||||
print!("I found the following source files:");
|
||||
for f in &files {
|
||||
print!(" {f}");
|
||||
print!(" {}", style(f).bold().yellow());
|
||||
}
|
||||
println!();
|
||||
files = ask("Which source files would you like to use? (specify multiple files separated by whitespace)")
|
||||
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();
|
||||
|
@ -295,6 +335,7 @@ fn main() {
|
|||
start: None,
|
||||
end: None,
|
||||
fast: Vec::new(),
|
||||
questions: Vec::new(),
|
||||
metadata: None
|
||||
},
|
||||
progress: Default::default()
|
||||
|
@ -314,14 +355,21 @@ fn main() {
|
|||
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
|
||||
if !project.progress.asked_start_end {
|
||||
project.source.start = Some(ask_time(format_args!(
|
||||
"Please take a look at the file {recording} and tell me the first second you want included"
|
||||
)));
|
||||
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"
|
||||
)));
|
||||
println!(
|
||||
"{} What is the first/last second you want included?",
|
||||
style("?").bold().yellow()
|
||||
);
|
||||
project.source.start = Some(ask_time("first"));
|
||||
project.source.end = Some(ask_time("last "));
|
||||
project.progress.asked_start_end = true;
|
||||
|
||||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||
|
@ -329,16 +377,16 @@ fn main() {
|
|||
|
||||
// ask the user about fast forward times
|
||||
if !project.progress.asked_fast {
|
||||
println!(
|
||||
"{} Which sections of the video do you want fast-forwarded? (0 to finish)",
|
||||
style("?").bold().yellow()
|
||||
);
|
||||
loop {
|
||||
let start = ask_time(format_args!(
|
||||
"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"
|
||||
));
|
||||
let start = ask_time("from");
|
||||
if start.seconds == 0 && start.micros == 0 {
|
||||
break;
|
||||
}
|
||||
let end = ask_time(format_args!(
|
||||
"Please tell me the last second you want fast-forwarded"
|
||||
));
|
||||
let end = ask_time("to ");
|
||||
project.source.fast.push((start, end));
|
||||
}
|
||||
project.progress.asked_fast = true;
|
||||
|
@ -346,6 +394,26 @@ fn main() {
|
|||
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
// ask the user about questions from the audience that should be subtitled
|
||||
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 video
|
||||
let mut videos = Vec::new();
|
||||
videos.push(if project.progress.rendered {
|
||||
|
@ -378,9 +446,18 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
println!("\x1B[1m ==> DONE :)\x1B[0m");
|
||||
println!();
|
||||
println!(
|
||||
" {} {}",
|
||||
style("==>").bold().cyan(),
|
||||
style("DONE :)").bold()
|
||||
);
|
||||
println!(" Videos:");
|
||||
for v in &videos {
|
||||
println!(" -> {v}");
|
||||
println!(
|
||||
" {} {}",
|
||||
style("->").bold().cyan(),
|
||||
style(v).bold().yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
160
src/question.rs
Normal file
160
src/question.rs
Normal file
|
@ -0,0 +1,160 @@
|
|||
use crate::{iotro::Language, Resolution};
|
||||
use fontconfig::Fontconfig;
|
||||
use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer};
|
||||
use std::sync::OnceLock;
|
||||
use svgwriter::{
|
||||
tags::{Group, Path, Rect, 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: String) -> 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("Question")
|
||||
);
|
||||
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(
|
||||
Rect::new()
|
||||
.with_fill("#040")
|
||||
.with_x(0)
|
||||
.with_y(0)
|
||||
.with_width(1920)
|
||||
.with_height(1080)
|
||||
);
|
||||
svg.push(self.g);
|
||||
svg
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn question() {
|
||||
let svg = Question::new(
|
||||
Resolution::FullHD,
|
||||
&Language::default(),
|
||||
"Hallo Welt! Dies ist eine sehr kluge Frage aus dem Publikum. Die Frage ist nämlich: Was ist eigentlich die Frage?".into()
|
||||
)
|
||||
.finish();
|
||||
std::fs::write("question.svg", svg.to_string_pretty()).unwrap();
|
||||
}
|
|
@ -13,6 +13,7 @@ use crate::{
|
|||
};
|
||||
use anyhow::{bail, Context};
|
||||
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||
use console::style;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::VecDeque,
|
||||
|
@ -227,7 +228,13 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
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 =
|
||||
ffprobe_audio("stream=sample_rate", &recording_txt)?.parse()?;
|
||||
let recording_mkv = self.recording_mkv();
|
||||
|
@ -261,7 +268,12 @@ impl<'a> Renderer<'a> {
|
|||
});
|
||||
let metadata = project.source.metadata.as_ref().unwrap();
|
||||
|
||||
println!("\x1B[1m ==> Preparing assets ...\x1B[0m");
|
||||
println!();
|
||||
println!(
|
||||
" {} {}",
|
||||
style("==>").bold().cyan(),
|
||||
style("Preparing assets ...").bold()
|
||||
);
|
||||
|
||||
// render intro to svg then mp4
|
||||
let intro_svg = self.target.join("intro.svg");
|
||||
|
@ -508,7 +520,13 @@ impl<'a> Renderer<'a> {
|
|||
) -> anyhow::Result<PathBuf> {
|
||||
let input = self.video_file_output();
|
||||
let output = self.video_file_res(res);
|
||||
println!("\x1B[1m ==> Rescaling to {}p\x1B[0m", res.height());
|
||||
|
||||
println!();
|
||||
println!(
|
||||
" {} {}",
|
||||
style("==>").bold().cyan(),
|
||||
style(format!("Rescaling to {}p", res.height())).bold()
|
||||
);
|
||||
|
||||
let mut ffmpeg = Ffmpeg::new(FfmpegOutput {
|
||||
video_bitrate: Some(res.bitrate()),
|
||||
|
|
15
tmp.sh
Executable file
15
tmp.sh
Executable file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/busybox ash
|
||||
set -euo pipefail
|
||||
|
||||
rm tmp.mkv || true
|
||||
|
||||
ffmpeg -hide_banner \
|
||||
-loop 1 -r 25 -t 4 -i question.png \
|
||||
-filter_complex "
|
||||
gradients=s=2560x1440:d=4:c0=#000055:c1=#005500:x0=480:y0=540:x1=1440:y1=540[input];
|
||||
[0]fade=t=in:st=0:d=1:alpha=1,fade=t=out:st=3:d=1:alpha=1[overlay];
|
||||
[input][overlay]overlay=eval=frame:x=0:y=0[v]
|
||||
" \
|
||||
-map "[v]" \
|
||||
-c:v libsvtav1 -preset 1 -crf 18 \
|
||||
"tmp.mkv"
|
Loading…
Reference in a new issue