Skip to content

Commit 84fcd51

Browse files
authored
Cache video (#19)
Implements caching for videos which dramatically speeds up presentation generation. The generation for the Google demo for example goes from 20 seconds to 2 seconds. Unfortunately, `typst query` cannot extract the slides from the input file. It can only get the speaker notes. Since the video cache key requires the slide itself too, I therefore wrote some manual logic to extract the slide content. That's didn't work because (as always) parsing is hard. In this case, it also needs to handle slides such as ```typst #slide[ #set page(margin: 2em) #set text(size: 30pt) #align(left)[ ```typ #import "@preview/polylux:0.4.0": * #set page(paper: "presentation-16-9") #slide[ Hello #toolbox.pdfpc.speaker-note(" This page contains Hello ") ] ``` ] #toolbox.pdfpc.speaker-note(" To create the presentation, we use Typst. Typst is a new typesetting system that is similar to LaTeX. Here for example is a simple Typst document with one slide. The slide contains the text Hello and a speaker note with the text This page contains Hello ") ] ``` without parsing the nested `#slide` as another slide. Instead, the tool now uses the slide images for the cache key. This should work since ```sh $ diff 1.png _out/image/1.png $ diff 1.png _out/image/2.png Binary files 1.png and _out/image/2.png differ ``` Also fixes #14.
1 parent 419513f commit 84fcd51

File tree

11 files changed

+290
-172
lines changed

11 files changed

+290
-172
lines changed

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ jobs:
4141
with:
4242
key: "release-${{ matrix.target }}"
4343

44+
- uses: actions/cache@v4
45+
with:
46+
path: ~/.cache/typst
47+
key: typst
48+
4449
- run: |
4550
if [[ ${{ matrix.os }} = "windows-latest" ]]; then
4651
EXT=".exe"

.github/workflows/test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ jobs:
2222
with:
2323
prefix-key: 'test'
2424

25+
- uses: actions/cache@v4
26+
with:
27+
path: ~/.cache/typst
28+
key: typst
29+
2530
- uses: FedericoCarboni/setup-ffmpeg@v3
2631

2732
- uses: taiki-e/install-action@v2

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ tracing = "0.1"
1717
transformrs = "0.7"
1818
serde_json = "1.0.138"
1919
serde = { version = "1", features = ["derive"] }
20+
sha2 = "0.10.8"
2021

2122
[dev-dependencies]
2223
assert_cmd = "2"

src/audio.rs

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
use crate::image::NewSlide;
21
use crate::path::audio_cache_key_path;
32
use crate::path::audio_path;
3+
use crate::slide::Slide;
44
use serde::Deserialize;
55
use serde::Serialize;
66
use std::fs::File;
@@ -11,16 +11,16 @@ use transformrs::Keys;
1111
use transformrs::Provider;
1212

1313
#[derive(Debug, Serialize, Deserialize)]
14-
struct CacheKey {
14+
struct AudioCacheKey {
1515
text: String,
1616
config: TTSConfig,
1717
}
1818

19-
fn write_cache_key(dir: &str, slide: &NewSlide, config: &TTSConfig) {
19+
fn write_cache_key(dir: &str, slide: &Slide, config: &TTSConfig) {
2020
let txt_path = audio_cache_key_path(dir, slide);
2121
let mut file = File::create(txt_path).unwrap();
22-
let text = slide.note.clone();
23-
let cache_key = CacheKey {
22+
let text = slide.speaker_note.clone();
23+
let cache_key = AudioCacheKey {
2424
text,
2525
config: config.clone(),
2626
};
@@ -29,28 +29,28 @@ fn write_cache_key(dir: &str, slide: &NewSlide, config: &TTSConfig) {
2929
}
3030

3131
/// Whether the audio file for the given slide exists and is for the same slide.
32-
fn is_cached(dir: &str, slide: &NewSlide, config: &TTSConfig, audio_ext: &str) -> bool {
32+
fn is_cached(dir: &str, slide: &Slide, config: &TTSConfig, audio_ext: &str) -> bool {
3333
let txt_path = audio_cache_key_path(dir, slide);
3434
let audio_path = audio_path(dir, slide, audio_ext);
3535
if !txt_path.exists() || !audio_path.exists() {
3636
return false;
3737
}
38-
let contents = std::fs::read_to_string(txt_path).unwrap();
39-
let text = slide.note.clone();
40-
let cache_key = CacheKey {
38+
let stored_key = std::fs::read_to_string(txt_path).unwrap();
39+
let text = slide.speaker_note.clone();
40+
let cache_key = AudioCacheKey {
4141
text,
4242
config: config.clone(),
4343
};
44-
let serialized = serde_json::to_string(&cache_key).unwrap();
45-
contents == serialized
44+
let current_info = serde_json::to_string(&cache_key).unwrap();
45+
stored_key == current_info
4646
}
4747

4848
#[allow(clippy::too_many_arguments)]
4949
async fn generate_audio_file(
5050
provider: &Provider,
5151
keys: &Keys,
5252
dir: &str,
53-
slide: &NewSlide,
53+
slide: &Slide,
5454
cache: bool,
5555
config: &TTSConfig,
5656
model: &Option<String>,
@@ -78,10 +78,11 @@ async fn generate_audio_file(
7878
}
7979
_ => get_key(keys, provider),
8080
};
81-
let msg = &slide.note;
82-
if cache && is_cached(dir, slide, config, audio_ext) {
81+
let msg = &slide.speaker_note;
82+
let is_cached = cache && is_cached(dir, slide, config, audio_ext);
83+
if is_cached {
8384
tracing::info!(
84-
"Skipping audio generation for slide {} due to cache",
85+
"Slide {}: Skipping audio generation due to cache",
8586
slide.idx
8687
);
8788
return;
@@ -101,15 +102,15 @@ async fn generate_audio_file(
101102
}
102103
let mut file = File::create(path).unwrap();
103104
file.write_all(&bytes).unwrap();
104-
if cache {
105+
if cache && !is_cached {
105106
write_cache_key(dir, slide, config);
106107
}
107108
}
108109

109110
pub async fn generate_audio_files(
110111
provider: &Provider,
111112
dir: &str,
112-
slides: &Vec<NewSlide>,
113+
slides: &Vec<Slide>,
113114
cache: bool,
114115
config: &TTSConfig,
115116
model: &Option<String>,
@@ -119,8 +120,8 @@ pub async fn generate_audio_files(
119120
// keys from environment variables).
120121
let keys = transformrs::load_keys("not_used.env");
121122
for slide in slides {
122-
let idx = crate::path::idx(slide);
123-
tracing::info!("Generating audio file for slide {idx}");
123+
let idx = slide.idx;
124+
tracing::info!("Slide {idx}: Generating audio file...");
124125
generate_audio_file(provider, &keys, dir, slide, cache, config, model, audio_ext).await;
125126
}
126127
}

src/image.rs

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,7 @@
11
use crate::path::PathStr;
2-
use serde::Deserialize;
3-
use serde::Serialize;
4-
use serde_json::Value;
52
use std::path::Path;
63
use std::path::PathBuf;
74

8-
#[derive(Clone, Debug, Serialize, Deserialize)]
9-
pub struct NewSlide {
10-
pub idx: u64,
11-
#[allow(dead_code)]
12-
pub overlay: u64,
13-
#[allow(dead_code)]
14-
pub logical_slide: u64,
15-
pub note: String,
16-
}
17-
18-
impl NewSlide {
19-
fn new(idx: &Value, overlay: &Value, logical_slide: &Value, note: &Value) -> Self {
20-
let idx = idx.get("v").and_then(|v| v.as_u64()).unwrap();
21-
let overlay = overlay.get("v").and_then(|v| v.as_u64()).unwrap();
22-
let logical_slide = logical_slide.get("v").and_then(|v| v.as_u64()).unwrap();
23-
let note = note.get("v").and_then(|v| v.as_str()).unwrap();
24-
Self {
25-
idx,
26-
overlay,
27-
logical_slide,
28-
note: note.to_string(),
29-
}
30-
}
31-
}
32-
33-
fn query_presenter_notes(input: &str) -> Value {
34-
let output = std::process::Command::new("typst")
35-
.arg("query")
36-
.arg(input)
37-
.arg("<pdfpc>")
38-
.arg("--field=value")
39-
.output()
40-
.expect("Failed to run typst presenter-notes command");
41-
42-
let text = String::from_utf8_lossy(&output.stdout);
43-
match serde_json::from_str::<Value>(&text) {
44-
Ok(json) => json,
45-
Err(e) => {
46-
tracing::error!("Error parsing JSON: {}", e);
47-
tracing::error!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
48-
std::process::exit(1);
49-
}
50-
}
51-
}
52-
53-
pub fn presenter_notes(input: &str) -> Vec<NewSlide> {
54-
let json = query_presenter_notes(input);
55-
56-
let values = json.as_array().expect("Expected JSON array");
57-
58-
let mut slides = Vec::new();
59-
60-
for i in 0..values.len() {
61-
let note = &values[i];
62-
if let Some(obj) = note.as_object() {
63-
if let Some(t) = obj.get("t") {
64-
if t == "NewSlide" {
65-
let idx = &values[i + 1];
66-
let overlay = &values[i + 2];
67-
let logical_slide = &values[i + 3];
68-
let note = &values[i + 4];
69-
let slide = NewSlide::new(idx, overlay, logical_slide, note);
70-
slides.push(slide);
71-
}
72-
}
73-
}
74-
}
75-
76-
slides
77-
}
78-
795
pub fn generate_images(input: &PathBuf, dir: &str) {
806
let image_dir = Path::new(dir).join("image");
817
if !image_dir.exists() {

src/main.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod audio;
22
mod image;
33
mod path;
4+
mod slide;
45
mod video;
56

67
use clap::Parser;
@@ -126,13 +127,38 @@ fn init_subscriber(level: tracing::Level) -> Result<(), SetGlobalDefaultError> {
126127
tracing::subscriber::set_global_default(subscriber)
127128
}
128129

129-
/// Copy the input file to the output directory.
130+
fn include_includes(input_dir: &Path, content: &str) -> String {
131+
let mut output = String::new();
132+
for line in content.lines() {
133+
if line.starts_with("#include") {
134+
let include = line.split_whitespace().nth(1).unwrap().trim_matches('"');
135+
let include_path = input_dir.join(include);
136+
tracing::info!("Including file: {}", include_path.display());
137+
let content = std::fs::read_to_string(include_path).unwrap();
138+
for line in content.lines() {
139+
output.push_str(line);
140+
output.push('\n');
141+
}
142+
} else {
143+
output.push_str(line);
144+
output.push('\n');
145+
}
146+
}
147+
output
148+
}
149+
150+
/// Copy the Typst input file to the output directory.
130151
///
131-
/// Typst requires the input to be present in the project directory.
132-
fn copy_input(input: &str, dir: &str) -> PathBuf {
133-
let path = Path::new(dir).join("input.typ");
134-
std::fs::copy(input, &path).unwrap();
135-
path
152+
/// This is necessary because Typst requires the input to be present in the
153+
/// project directory.
154+
fn copy_input_with_includes(dir: &str, input: &str) -> PathBuf {
155+
let output_path = Path::new(dir).join("input.typ");
156+
let content = std::fs::read_to_string(input).unwrap();
157+
let input_dir = Path::new(input).parent().unwrap();
158+
let content = include_includes(input_dir, &content);
159+
std::fs::write(&output_path, content).unwrap();
160+
161+
output_path
136162
}
137163

138164
#[tokio::main]
@@ -149,7 +175,7 @@ async fn main() {
149175
if !path.exists() {
150176
std::fs::create_dir_all(path).unwrap();
151177
}
152-
let input = copy_input(&args.input, dir);
178+
let input = copy_input_with_includes(dir, &args.input);
153179

154180
let provider = args.provider.map(|p| provider_from_str(&p));
155181
let provider = provider.unwrap_or(Provider::DeepInfra);
@@ -165,7 +191,10 @@ async fn main() {
165191
language_code: args.language_code.clone(),
166192
};
167193

168-
let slides = image::presenter_notes(&args.input);
194+
let slides = slide::slides(input.to_str().unwrap());
195+
if slides.is_empty() {
196+
panic!("No slides found in input file: {}", args.input);
197+
}
169198
image::generate_images(&input, dir);
170199
let audio_ext = config.output_format.clone().unwrap_or("mp3".to_string());
171200
audio::generate_audio_files(
@@ -180,7 +209,7 @@ async fn main() {
180209
.await;
181210
// Using mkv by default because it supports more audio formats.
182211
let output = "out.mkv";
183-
video::generate_video(dir, &slides, output, &audio_ext);
212+
video::generate_video(dir, &slides, args.cache, &config, output, &audio_ext);
184213
if args.release {
185214
video::generate_release_video(dir, output, "release.mp4", &args.audio_codec);
186215
}

src/path.rs

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::image::NewSlide;
1+
use crate::slide::Slide;
22
use std::path::Path;
33
use std::path::PathBuf;
44

@@ -12,35 +12,36 @@ impl PathStr for PathBuf {
1212
}
1313
}
1414

15-
pub fn idx(slide: &NewSlide) -> u64 {
16-
// Typst png files start at one, while slide.idx at zero.
17-
slide.idx + 1
18-
}
19-
20-
pub fn audio_path(dir: &str, slide: &NewSlide, audio_ext: &str) -> PathBuf {
21-
let idx = idx(slide);
15+
pub fn audio_path(dir: &str, slide: &Slide, audio_ext: &str) -> PathBuf {
16+
let idx = slide.idx;
2217
let filename = format!("{idx}.{audio_ext}");
2318
Path::new(dir).join("audio").join(filename)
2419
}
2520

26-
pub fn image_path(dir: &str, slide: &NewSlide) -> PathBuf {
27-
let idx = idx(slide);
21+
pub fn image_path(dir: &str, slide: &Slide) -> PathBuf {
22+
let idx = slide.idx;
2823
let filename = format!("{idx}.png");
2924
Path::new(dir).join("image").join(filename)
3025
}
3126

32-
pub fn audio_cache_key_path(dir: &str, slide: &NewSlide) -> PathBuf {
33-
let idx = idx(slide);
27+
pub fn audio_cache_key_path(dir: &str, slide: &Slide) -> PathBuf {
28+
let idx = slide.idx;
3429
let filename = format!("{idx}.audio.cache_key");
3530
Path::new(dir).join("audio").join(filename)
3631
}
3732

33+
pub fn video_cache_key_path(dir: &str, slide: &Slide) -> PathBuf {
34+
let idx = slide.idx;
35+
let filename = format!("{idx}.video.cache_key");
36+
Path::new(dir).join("video").join(filename)
37+
}
38+
3839
pub fn video_dir_name() -> &'static str {
3940
"video"
4041
}
4142

42-
pub fn video_path(dir: &str, slide: &NewSlide) -> PathBuf {
43-
let idx = idx(slide);
43+
pub fn video_path(dir: &str, slide: &Slide) -> PathBuf {
44+
let idx = slide.idx;
4445
// Using mkv by default because it supports more audio formats.
4546
let filename = format!("{idx}.mkv");
4647
Path::new(dir).join(video_dir_name()).join(filename)

0 commit comments

Comments
 (0)