Compare commits
No commits in common. "52c89dc95af6814a23324fe98cf42912da9339eb" and "b6bd1be12e8df137c1667de42a9eba879782c18d" have entirely different histories.
52c89dc95a
...
b6bd1be12e
6 changed files with 207 additions and 277 deletions
14
src/iotro.rs
14
src/iotro.rs
|
@ -24,9 +24,7 @@ pub struct Language<'a> {
|
|||
download_videos: &'a str,
|
||||
questions_feedback: &'a str,
|
||||
// metadata
|
||||
pub(crate) from: &'a str,
|
||||
// questions
|
||||
pub(crate) question: &'a str
|
||||
pub(crate) from: &'a str
|
||||
}
|
||||
|
||||
pub const GERMAN: Language<'static> = Language {
|
||||
|
@ -60,9 +58,7 @@ pub const GERMAN: Language<'static> = Language {
|
|||
download_videos: "Videos herunterladen",
|
||||
questions_feedback: "Fragen, Vorschläge und Feedback",
|
||||
|
||||
from: "vom",
|
||||
|
||||
question: "Frage"
|
||||
from: "vom"
|
||||
};
|
||||
|
||||
pub const BRITISH: Language<'static> = Language {
|
||||
|
@ -91,7 +87,7 @@ pub const BRITISH: Language<'static> = Language {
|
|||
3 | 23 => "rd",
|
||||
_ => "th"
|
||||
};
|
||||
format!("{}{th} {month} {:04}", d.day, d.year)
|
||||
format!("{:02}{th} {month} {:04}", d.day, d.year)
|
||||
},
|
||||
|
||||
lecture_from: "Lecture from",
|
||||
|
@ -102,9 +98,7 @@ pub const BRITISH: Language<'static> = Language {
|
|||
download_videos: "Download videos",
|
||||
questions_feedback: "Questions, Suggestions and Feedback",
|
||||
|
||||
from: "from",
|
||||
|
||||
question: "Question"
|
||||
from: "from"
|
||||
};
|
||||
|
||||
impl Default for Language<'static> {
|
||||
|
|
209
src/main.rs
209
src/main.rs
|
@ -3,27 +3,30 @@
|
|||
#![forbid(elided_lifetimes_in_paths, unsafe_code)]
|
||||
|
||||
mod iotro;
|
||||
mod preset;
|
||||
mod project;
|
||||
mod question;
|
||||
mod render;
|
||||
mod time;
|
||||
|
||||
use self::{
|
||||
project::{Project, ProjectLecture, ProjectSource, Resolution},
|
||||
render::Renderer,
|
||||
time::{parse_date, parse_time, Time}
|
||||
iotro::Language,
|
||||
question::Question,
|
||||
render::{ffmpeg::FfmpegOutputFormat, Renderer},
|
||||
time::{parse_date, parse_time, Date, Time}
|
||||
};
|
||||
use crate::preset::Preset;
|
||||
use camino::Utf8PathBuf as PathBuf;
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use rational::Rational;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
#[cfg(feature = "mem_limit")]
|
||||
use std::sync::RwLock;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fmt::Display,
|
||||
fs,
|
||||
io::{self, BufRead as _, Write}
|
||||
io::{self, BufRead as _, Write},
|
||||
str::FromStr
|
||||
};
|
||||
|
||||
#[cfg(feature = "mem_limit")]
|
||||
|
@ -35,24 +38,34 @@ struct Args {
|
|||
#[clap(short = 'C', long, default_value = ".")]
|
||||
directory: PathBuf,
|
||||
|
||||
/// The preset of the lecture. Can be a toml file or a known course slug.
|
||||
#[clap(short, long)]
|
||||
preset: String,
|
||||
/// The slug of the course, e.g. "23ws-malo2".
|
||||
#[clap(short = 'c', long, default_value = "23ws-malo2")]
|
||||
course: String,
|
||||
|
||||
/// The label of the course, e.g. "Mathematische Logik II".
|
||||
#[clap(short, long, default_value = "Mathematische Logik II")]
|
||||
label: String,
|
||||
|
||||
/// The docent of the course, e.g. "Prof. E. Grädel".
|
||||
#[clap(short, long, default_value = "Prof. E. Grädel")]
|
||||
docent: String,
|
||||
|
||||
/// The language of the lecture. Used for the intro and outro frame.
|
||||
#[clap(short = 'L', long, default_value = "de")]
|
||||
lang: Language<'static>,
|
||||
|
||||
#[cfg(feature = "mem_limit")]
|
||||
/// The memory limit for external tools like ffmpeg.
|
||||
#[clap(short, long, default_value = "12G")]
|
||||
mem_limit: String,
|
||||
|
||||
/// Transcode the final video clip down to the minimum resolution specified. If not
|
||||
/// specified, the default value from the preset is used.
|
||||
/// Transcode the final video clip down to the minimum resolution specified.
|
||||
#[clap(short, long)]
|
||||
transcode: Option<Resolution>,
|
||||
|
||||
/// Transcode starts at this resolution, or the source resolution, whichever is lower.
|
||||
/// If not specified, the default value from the preset is used.
|
||||
#[clap(short = 'T', long)]
|
||||
transcode_start: Option<Resolution>,
|
||||
#[clap(short = 'T', long, default_value = "1440p")]
|
||||
transcode_start: Resolution,
|
||||
|
||||
/// Treat the audio as stereo. By default, only one channel from the input stereo will
|
||||
/// be used, assuming either the other channel is backup or the same as the used.
|
||||
|
@ -60,6 +73,159 @@ struct Args {
|
|||
stereo: bool
|
||||
}
|
||||
|
||||
macro_rules! resolutions {
|
||||
($($res:ident: $width:literal x $height:literal at $bitrate:literal in $format:ident),+) => {
|
||||
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
enum Resolution {
|
||||
$(
|
||||
#[doc = concat!(stringify!($width), "x", stringify!($height))]
|
||||
$res
|
||||
),+
|
||||
}
|
||||
|
||||
const NUM_RESOLUTIONS: usize = {
|
||||
let mut num = 0;
|
||||
$(num += 1; stringify!($res);)+
|
||||
num
|
||||
};
|
||||
|
||||
impl Resolution {
|
||||
fn values() -> [Self; NUM_RESOLUTIONS] {
|
||||
[$(Self::$res),+]
|
||||
}
|
||||
|
||||
fn width(self) -> usize {
|
||||
match self {
|
||||
$(Self::$res => $width),+
|
||||
}
|
||||
}
|
||||
|
||||
fn height(self) -> usize {
|
||||
match self {
|
||||
$(Self::$res => $height),+
|
||||
}
|
||||
}
|
||||
|
||||
fn bitrate(self) -> u64 {
|
||||
match self {
|
||||
$(Self::$res => $bitrate),+
|
||||
}
|
||||
}
|
||||
|
||||
fn format(self) -> FfmpegOutputFormat {
|
||||
match self {
|
||||
$(Self::$res => FfmpegOutputFormat::$format),+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Resolution {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
Ok(match s {
|
||||
$(concat!(stringify!($height), "p") => Self::$res,)+
|
||||
_ => anyhow::bail!("Unknown Resolution: {s:?}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolutions! {
|
||||
nHD: 640 x 360 at 500_000 in AvcAac,
|
||||
HD: 1280 x 720 at 1_000_000 in AvcAac,
|
||||
FullHD: 1920 x 1080 at 750_000 in Av1Opus,
|
||||
WQHD: 2560 x 1440 at 1_000_000 in Av1Opus,
|
||||
// TODO qsx muss mal sagen wieviel bitrate für 4k
|
||||
UHD: 3840 x 2160 at 2_000_000 in Av1Opus
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Project {
|
||||
lecture: ProjectLecture,
|
||||
source: ProjectSource,
|
||||
progress: ProjectProgress
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ProjectLecture {
|
||||
course: String,
|
||||
label: String,
|
||||
docent: String,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
date: Date,
|
||||
#[serde(default = "Default::default")]
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
lang: Language<'static>
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ProjectSource {
|
||||
files: Vec<String>,
|
||||
stereo: bool,
|
||||
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
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>
|
||||
}
|
||||
|
||||
#[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 {
|
||||
#[serde(default)]
|
||||
preprocessed: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_start_end: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_fast: bool,
|
||||
|
||||
#[serde(default)]
|
||||
asked_questions: bool,
|
||||
|
||||
#[serde(default)]
|
||||
rendered_assets: bool,
|
||||
|
||||
#[serde(default)]
|
||||
rendered: bool,
|
||||
|
||||
#[serde(default)]
|
||||
transcoded: BTreeSet<Resolution>
|
||||
}
|
||||
|
||||
fn ask(question: impl Display) -> String {
|
||||
let mut stdout = io::stdout().lock();
|
||||
let mut stdin = io::stdin().lock();
|
||||
|
@ -116,8 +282,7 @@ fn main() {
|
|||
|
||||
// process arguments
|
||||
let directory = args.directory.canonicalize_utf8().unwrap();
|
||||
let preset = Preset::find(&args.preset).unwrap();
|
||||
let course = preset.course;
|
||||
let course = args.course;
|
||||
|
||||
// let's see if we need to initialise the project
|
||||
let project_path = directory.join("project.toml");
|
||||
|
@ -162,10 +327,10 @@ fn main() {
|
|||
let project = Project {
|
||||
lecture: ProjectLecture {
|
||||
course,
|
||||
label: preset.label,
|
||||
docent: preset.docent,
|
||||
label: args.label,
|
||||
docent: args.docent,
|
||||
date,
|
||||
lang: preset.lang
|
||||
lang: args.lang
|
||||
},
|
||||
source: ProjectSource {
|
||||
files,
|
||||
|
@ -274,10 +439,10 @@ fn main() {
|
|||
});
|
||||
|
||||
// rescale the video
|
||||
if let Some(lowest_res) = args.transcode.or(preset.transcode) {
|
||||
if let Some(lowest_res) = args.transcode {
|
||||
for res in Resolution::values().into_iter().rev() {
|
||||
if res > project.source.metadata.as_ref().unwrap().source_res
|
||||
|| res > args.transcode_start.unwrap_or(preset.transcode_start)
|
||||
|| res > args.transcode_start
|
||||
|| res < lowest_res
|
||||
{
|
||||
continue;
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
//! 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(crate) struct Preset {
|
||||
// options for the intro slide
|
||||
pub(crate) course: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) docent: String,
|
||||
|
||||
/// Course language
|
||||
#[serde(default = "Default::default")]
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) lang: Language<'static>,
|
||||
|
||||
// coding options
|
||||
pub(crate) transcode_start: Resolution,
|
||||
pub(crate) transcode: Option<Resolution>
|
||||
}
|
||||
|
||||
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: Resolution::WQHD,
|
||||
transcode: Some(Resolution::nHD)
|
||||
}
|
||||
}
|
||||
|
||||
fn preset_24ss_algomod() -> Preset {
|
||||
Preset {
|
||||
course: "24ss-algomod".into(),
|
||||
label: "Algorithmische Modelltheorie".into(),
|
||||
docent: "Prof. E. Grädel".into(),
|
||||
lang: GERMAN,
|
||||
transcode_start: Resolution::WQHD,
|
||||
transcode: Some(Resolution::HD)
|
||||
}
|
||||
}
|
||||
|
||||
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: Resolution::WQHD,
|
||||
transcode: Some(Resolution::HD)
|
||||
}
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub(crate) 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:?}")
|
||||
})
|
||||
}
|
||||
}
|
164
src/project.rs
164
src/project.rs
|
@ -1,164 +0,0 @@
|
|||
//! 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::{collections::BTreeSet, str::FromStr};
|
||||
|
||||
macro_rules! resolutions {
|
||||
($($res:ident: $width:literal x $height:literal at $bitrate:literal in $format:ident),+) => {
|
||||
#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
pub(crate) enum Resolution {
|
||||
$(
|
||||
#[doc = concat!(stringify!($width), "x", stringify!($height))]
|
||||
$res
|
||||
),+
|
||||
}
|
||||
|
||||
const NUM_RESOLUTIONS: usize = {
|
||||
let mut num = 0;
|
||||
$(num += 1; stringify!($res);)+
|
||||
num
|
||||
};
|
||||
|
||||
impl Resolution {
|
||||
pub(crate) fn values() -> [Self; NUM_RESOLUTIONS] {
|
||||
[$(Self::$res),+]
|
||||
}
|
||||
|
||||
pub(crate) fn width(self) -> usize {
|
||||
match self {
|
||||
$(Self::$res => $width),+
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn height(self) -> usize {
|
||||
match self {
|
||||
$(Self::$res => $height),+
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bitrate(self) -> u64 {
|
||||
match self {
|
||||
$(Self::$res => $bitrate),+
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format(self) -> FfmpegOutputFormat {
|
||||
match self {
|
||||
$(Self::$res => FfmpegOutputFormat::$format),+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Resolution {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
Ok(match s {
|
||||
$(concat!(stringify!($height), "p") => Self::$res,)+
|
||||
_ => anyhow::bail!("Unknown Resolution: {s:?}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolutions! {
|
||||
nHD: 640 x 360 at 500_000 in AvcAac,
|
||||
HD: 1280 x 720 at 1_000_000 in AvcAac,
|
||||
FullHD: 1920 x 1080 at 750_000 in Av1Opus,
|
||||
WQHD: 2560 x 1440 at 1_000_000 in Av1Opus,
|
||||
// TODO qsx muss mal sagen wieviel bitrate für 4k
|
||||
UHD: 3840 x 2160 at 2_000_000 in Av1Opus
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct Project {
|
||||
pub(crate) lecture: ProjectLecture,
|
||||
pub(crate) source: ProjectSource,
|
||||
pub(crate) progress: ProjectProgress
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct ProjectLecture {
|
||||
pub(crate) course: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) docent: String,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) date: Date,
|
||||
#[serde(default = "Default::default")]
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) lang: Language<'static>
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct ProjectSource {
|
||||
pub(crate) files: Vec<String>,
|
||||
pub(crate) stereo: bool,
|
||||
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
pub(crate) start: Option<Time>,
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
pub(crate) end: Option<Time>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr)>")]
|
||||
pub(crate) fast: Vec<(Time, Time)>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "Vec<(DisplayFromStr, DisplayFromStr, _)>")]
|
||||
pub(crate) questions: Vec<(Time, Time, String)>,
|
||||
|
||||
pub(crate) metadata: Option<ProjectSourceMetadata>
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub(crate) struct ProjectSourceMetadata {
|
||||
/// The duration of the source video.
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) source_duration: Time,
|
||||
/// The FPS of the source video.
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) source_fps: Rational,
|
||||
/// The time base of the source video.
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub(crate) source_tbn: Rational,
|
||||
/// The resolution of the source video.
|
||||
pub(crate) source_res: Resolution,
|
||||
/// The sample rate of the source audio.
|
||||
pub(crate) source_sample_rate: u32
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
pub(crate) struct ProjectProgress {
|
||||
#[serde(default)]
|
||||
pub(crate) preprocessed: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) asked_start_end: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) asked_fast: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) asked_questions: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) rendered_assets: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) rendered: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub(crate) transcoded: BTreeSet<Resolution>
|
||||
}
|
|
@ -3,7 +3,7 @@ use fontconfig::Fontconfig;
|
|||
use harfbuzz_rs::{Face, Font, Owned, UnicodeBuffer};
|
||||
use std::sync::OnceLock;
|
||||
use svgwriter::{
|
||||
tags::{Group, Path, TSpan, TagWithPresentationAttributes as _, Text},
|
||||
tags::{Group, Path, Rect, TSpan, TagWithPresentationAttributes as _, Text},
|
||||
Data, Graphic, Transform
|
||||
};
|
||||
|
||||
|
@ -122,7 +122,7 @@ impl Question {
|
|||
.with_dominant_baseline("middle")
|
||||
.with_text_anchor("middle")
|
||||
.with_font_weight(600)
|
||||
.append(lang.question.to_owned())
|
||||
.append("Question")
|
||||
);
|
||||
g.push(text);
|
||||
|
||||
|
@ -138,3 +138,15 @@ impl Question {
|
|||
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();
|
||||
}
|
||||
|
|
|
@ -7,10 +7,10 @@ use self::{
|
|||
};
|
||||
use crate::{
|
||||
iotro::{intro, outro},
|
||||
project::{Project, ProjectLecture, ProjectSourceMetadata, Resolution},
|
||||
question::Question,
|
||||
render::ffmpeg::{Ffmpeg, FfmpegInput},
|
||||
time::{format_date, format_time, Time}
|
||||
time::{format_date, format_time, Time},
|
||||
Project, ProjectLecture, ProjectSourceMetadata, Resolution
|
||||
};
|
||||
use anyhow::{bail, Context};
|
||||
use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
|
||||
|
|
Loading…
Reference in a new issue