diff --git a/Cargo.toml b/Cargo.toml index bc0f991..0faaa6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ license = "EPL-2.0" anyhow = "1.0" camino = "1.1" 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"] } diff --git a/question.png b/question.png new file mode 100644 index 0000000..4172624 Binary files /dev/null and b/question.png differ diff --git a/src/iotro.rs b/src/iotro.rs index 94eca89..98b5a50 100644 --- a/src/iotro.rs +++ b/src/iotro.rs @@ -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 diff --git a/src/main.rs b/src/main.rs index 6c9a7ee..af93579 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,16 +3,18 @@ #![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 rational::Rational; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; diff --git a/src/question.rs b/src/question.rs new file mode 100644 index 0000000..f38a8b5 --- /dev/null +++ b/src/question.rs @@ -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>> = 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(); +} diff --git a/tmp.sh b/tmp.sh new file mode 100755 index 0000000..df71030 --- /dev/null +++ b/tmp.sh @@ -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"