initial commit
This commit is contained in:
commit
d59ca4aceb
5 changed files with 405 additions and 0 deletions
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# test files
|
||||||
|
/2310*/
|
||||||
|
|
||||||
|
# rust/cargo
|
||||||
|
/target/
|
||||||
|
/Cargo.lock
|
||||||
|
|
||||||
|
# git
|
||||||
|
*.orig
|
||||||
|
|
||||||
|
# emacs
|
||||||
|
*~
|
||||||
|
\#*#
|
||||||
|
.#*#
|
||||||
|
|
||||||
|
# intellij
|
||||||
|
/.idea/
|
||||||
|
*.ipr
|
||||||
|
*.iml
|
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# -*- eval: (cargo-minor-mode 1) -*-
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "render_video"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
camino = "1.1"
|
||||||
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
|
ffmpeg = { package = "ffmpeg-next", version = "6.0" }
|
||||||
|
serde = { version = "1.0.188", features = ["derive"] }
|
||||||
|
serde_with = "3.4"
|
||||||
|
toml = { package = "basic-toml", version = "0.1.4" }
|
38
rustfmt.toml
Normal file
38
rustfmt.toml
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
edition = "2021"
|
||||||
|
max_width = 90
|
||||||
|
newline_style = "Unix"
|
||||||
|
unstable_features = true
|
||||||
|
|
||||||
|
# skip generated files
|
||||||
|
format_generated_files = false
|
||||||
|
|
||||||
|
# always use tabs.
|
||||||
|
hard_tabs = true
|
||||||
|
tab_spaces = 4
|
||||||
|
|
||||||
|
# commas inbetween but not after
|
||||||
|
match_block_trailing_comma = true
|
||||||
|
trailing_comma = "Never"
|
||||||
|
|
||||||
|
# fix my imports for me
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
#group_imports = "One"
|
||||||
|
|
||||||
|
# format everything
|
||||||
|
format_code_in_doc_comments = true
|
||||||
|
format_macro_matchers = true
|
||||||
|
|
||||||
|
# don't keep outdated syntax
|
||||||
|
use_field_init_shorthand = true
|
||||||
|
use_try_shorthand = true
|
||||||
|
|
||||||
|
# condense Struct { _, _ } to Struct { .. }
|
||||||
|
condense_wildcard_suffixes = true
|
||||||
|
|
||||||
|
# prefer foo(Bar { \n }) over foo(\nBar { \n }\n)
|
||||||
|
overflow_delimited_expr = true
|
||||||
|
|
||||||
|
# I wish there was a way to allow 0..n but not a + 1..b + 2
|
||||||
|
# However, the later looks so terible that I use spaces everywhere
|
||||||
|
# https://github.com/rust-lang/rustfmt/issues/3367
|
||||||
|
spaces_around_ranges = true
|
122
src/main.rs
Normal file
122
src/main.rs
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
#![warn(rust_2018_idioms)]
|
||||||
|
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||||
|
|
||||||
|
mod time;
|
||||||
|
|
||||||
|
use crate::time::{parse_date, parse_time, Date, Time};
|
||||||
|
use camino::Utf8PathBuf as PathBuf;
|
||||||
|
use clap::Parser;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DisplayFromStr};
|
||||||
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
fs,
|
||||||
|
io::{self, BufRead as _, Write}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct Args {
|
||||||
|
#[clap(short = 'C', long, default_value = ".")]
|
||||||
|
directory: PathBuf,
|
||||||
|
|
||||||
|
#[clap(short = 'c', long, default_value = "23ws-malo")]
|
||||||
|
course: String
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct Project {
|
||||||
|
lecture: ProjectLecture,
|
||||||
|
source: ProjectSource
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 = "DisplayFromStr")]
|
||||||
|
first_file_start: Time,
|
||||||
|
#[serde_as(as = "DisplayFromStr")]
|
||||||
|
last_file_end: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// process arguments
|
||||||
|
let directory = args.directory.canonicalize_utf8().unwrap();
|
||||||
|
let course = args.course;
|
||||||
|
|
||||||
|
// let's see if we need to initialise the project
|
||||||
|
let project_path = directory.join("project.toml");
|
||||||
|
let project = if project_path.exists() {
|
||||||
|
toml::from_slice(&fs::read(&project_path).unwrap()).unwrap()
|
||||||
|
} else {
|
||||||
|
let dirname = directory.file_name().unwrap();
|
||||||
|
let date =
|
||||||
|
parse_date(dirname).expect("Directory name is not in the expected format");
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for entry in directory.read_dir_utf8().unwrap() {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if (lower.ends_with(".mp4") || lower.ends_with(".mts"))
|
||||||
|
&& entry.file_type().unwrap().is_file()
|
||||||
|
{
|
||||||
|
files.push(String::from(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort_unstable();
|
||||||
|
assert!(!files.is_empty());
|
||||||
|
println!("I found the following source files: {files:?}");
|
||||||
|
|
||||||
|
let first_file_start = ask_time(format_args!(
|
||||||
|
"Please take a look at the file {} and tell me the first second you want included",
|
||||||
|
files.first().unwrap()
|
||||||
|
));
|
||||||
|
let last_file_end = ask_time(format_args!(
|
||||||
|
"Please take a look at the file {} and tell me the last second you want included",
|
||||||
|
files.last().unwrap()
|
||||||
|
));
|
||||||
|
|
||||||
|
let project = Project {
|
||||||
|
lecture: ProjectLecture { course, date },
|
||||||
|
source: ProjectSource {
|
||||||
|
files,
|
||||||
|
first_file_start,
|
||||||
|
last_file_end
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs::write(&project_path, toml::to_string(&project).unwrap().as_bytes()).unwrap();
|
||||||
|
project
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", toml::to_string(&project).unwrap());
|
||||||
|
}
|
210
src/time.rs
Normal file
210
src/time.rs
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
use anyhow::bail;
|
||||||
|
use std::{
|
||||||
|
fmt::{self, Display, Write as _},
|
||||||
|
str::FromStr
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Date {
|
||||||
|
year: u16,
|
||||||
|
month: u8,
|
||||||
|
day: u8
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Date {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||||
|
parse_date(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Date {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&format_date(*self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a date in YYMMDD format.
|
||||||
|
pub fn parse_date(s: &str) -> anyhow::Result<Date> {
|
||||||
|
let int: u32 = s.parse()?;
|
||||||
|
|
||||||
|
let year = int / 10000 + 2000;
|
||||||
|
if year / 10 != 202 {
|
||||||
|
bail!("Expected a date in 202X");
|
||||||
|
}
|
||||||
|
|
||||||
|
let month = int / 100 % 100;
|
||||||
|
if month < 1 || month > 12 {
|
||||||
|
bail!("Invalid month: {month}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let day = int % 100;
|
||||||
|
if day < 1 || day > 31 {
|
||||||
|
bail!("Invalid day: {day}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Date {
|
||||||
|
year: year as _,
|
||||||
|
month: month as _,
|
||||||
|
day: day as _
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a date in YYMMDD format.
|
||||||
|
pub fn format_date(d: Date) -> String {
|
||||||
|
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)]
|
||||||
|
pub struct Time {
|
||||||
|
seconds: u32,
|
||||||
|
micros: u32
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Time {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||||
|
parse_time(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Time {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&format_time(*self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a time in hh:mm:ss:sub or hh:mm:ss.micros format.
|
||||||
|
pub fn parse_time(s: &str) -> anyhow::Result<Time> {
|
||||||
|
let split = s.split(':');
|
||||||
|
let mut seconds = 0;
|
||||||
|
let mut micros = 0;
|
||||||
|
|
||||||
|
for (i, digits) in split.enumerate() {
|
||||||
|
// we cannot have more than 4 splits
|
||||||
|
if i > 3 {
|
||||||
|
bail!("Expected end of input, found ':'")
|
||||||
|
}
|
||||||
|
// we cannot process data after we have parsed micros
|
||||||
|
if micros != 0 {
|
||||||
|
bail!("Unexpected microsecond notation");
|
||||||
|
}
|
||||||
|
// the 4th split is subseconds, converting to micros
|
||||||
|
if i == 3 {
|
||||||
|
micros = digits.parse::<u32>()? * 1000000 / 60;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// add to seconds and potentially micros
|
||||||
|
seconds *= 60;
|
||||||
|
if let Some(idx) = digits.find('.') {
|
||||||
|
seconds += digits[0 .. idx].parse::<u32>()?;
|
||||||
|
let micros_digits = &digits[idx + 1 ..];
|
||||||
|
micros = micros_digits.parse::<u32>()?
|
||||||
|
* 10_u32.pow((6 - micros_digits.len()) as _);
|
||||||
|
} else {
|
||||||
|
seconds += digits.parse::<u32>()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Time { seconds, micros })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a time in hh:mm:ss.micros format.
|
||||||
|
pub fn format_time(t: Time) -> String {
|
||||||
|
let h = t.seconds / 3600;
|
||||||
|
let m = (t.seconds / 60) % 60;
|
||||||
|
let s = t.seconds % 60;
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
if h != 0 {
|
||||||
|
write!(out, "{h}:").unwrap();
|
||||||
|
}
|
||||||
|
if h != 0 || m != 0 {
|
||||||
|
write!(out, "{m:02}:{s:02}").unwrap();
|
||||||
|
} else {
|
||||||
|
write!(out, "{s}").unwrap();
|
||||||
|
}
|
||||||
|
if t.micros != 0 {
|
||||||
|
write!(out, ".{:06}", t.micros).unwrap();
|
||||||
|
out.truncate(out.trim_end_matches('0').len());
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_time_parse_only(s: &str, t: Time) {
|
||||||
|
let time = parse_time(s).unwrap();
|
||||||
|
assert_eq!(time, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_time(s: &str, t: Time) {
|
||||||
|
test_time_parse_only(s, t);
|
||||||
|
assert_eq!(format_time(t), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_0() {
|
||||||
|
test_time("0", Time {
|
||||||
|
seconds: 0,
|
||||||
|
micros: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_2_3() {
|
||||||
|
test_time("02:03", Time {
|
||||||
|
seconds: 123,
|
||||||
|
micros: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_1_2_3() {
|
||||||
|
test_time("1:02:03", Time {
|
||||||
|
seconds: 3723,
|
||||||
|
micros: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_subsecs() {
|
||||||
|
test_time_parse_only("1:02:03:30", Time {
|
||||||
|
seconds: 3723,
|
||||||
|
micros: 500000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_micros() {
|
||||||
|
test_time("1:02:03.5", Time {
|
||||||
|
seconds: 3723,
|
||||||
|
micros: 500000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue