alpha
Login
or
Join now
gwen.works
/
shapemaker
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
✨ Improve engine controllers API
author
Gwenn Le Bihan
date
7 months ago
(Oct 30, 2025, 11:00 PM +0100)
commit
2a10660f
2a10660f84001553e09235ac63ae224febbb7a3a
parent
bc955b22
bc955b227b8fa070e6f609e5bc2f4ea0d78e9a8c
+177
-156
6 changed files
Expand all
Collapse all
Unified
Split
src
video
encoders
mod.rs
encoding.rs
engine.rs
preview.html
server.rs
video.rs
+101
-1
src/video/encoders/mod.rs
Reviewed
···
1
1
-
use crate::video::engine::EngineOutput;
1
1
+
use crate::{
2
2
+
Video,
3
3
+
ui::{Log, Pretty},
4
4
+
video::{encoders::vgv::VGVTranscodeMode, engine::EngineOutput},
5
5
+
};
2
6
use anyhow::Result;
7
7
+
use std::path::PathBuf;
3
8
4
9
pub mod ffmpeg;
5
10
pub mod vgv;
···
13
18
format!("{}/{} frames", current, total,)
14
19
}
15
20
}
21
21
+
22
22
+
impl<C: Default> Video<C> {
23
23
+
pub(crate) fn setup_encoder(
24
24
+
&mut self,
25
25
+
output_path: impl Into<PathBuf>,
26
26
+
) -> Result<Box<dyn Encoder + Send>> {
27
27
+
let (width, height) =
28
28
+
self.initial_canvas.resolution_to_size_even(self.resolution);
29
29
+
30
30
+
let destination = output_path.into();
31
31
+
let pb = &self.progress_bars.encoding;
32
32
+
33
33
+
if destination.exists() {
34
34
+
std::fs::remove_file(&destination)?;
35
35
+
}
36
36
+
37
37
+
std::fs::create_dir_all(
38
38
+
&destination
39
39
+
.parent()
40
40
+
.expect("Given output file has no parent"),
41
41
+
)?;
42
42
+
43
43
+
Ok(match destination.full_extension() {
44
44
+
".vgv.html" => {
45
45
+
self.progress_bars.encoding.log(
46
46
+
"Selecting",
47
47
+
&format!(
48
48
+
"VGV encoder with HTML transcoding as {} ends with .vgv.html",
49
49
+
destination.pretty(),
50
50
+
),
51
51
+
);
52
52
+
53
53
+
Box::new(self.setup_vgv_encoder(
54
54
+
VGVTranscodeMode::ToHTML,
55
55
+
width as _,
56
56
+
height as _,
57
57
+
&self.initial_canvas,
58
58
+
destination,
59
59
+
)?)
60
60
+
}
61
61
+
".vgv" => {
62
62
+
self.progress_bars.encoding.log(
63
63
+
"Selecting",
64
64
+
&format!(
65
65
+
"VGV encoder as {} ends with .vgv (use .vgv.html for HTML transcoding)",
66
66
+
destination.pretty(),
67
67
+
),
68
68
+
);
69
69
+
70
70
+
Box::new(self.setup_vgv_encoder(
71
71
+
VGVTranscodeMode::None,
72
72
+
width as _,
73
73
+
height as _,
74
74
+
&self.initial_canvas,
75
75
+
destination,
76
76
+
)?)
77
77
+
}
78
78
+
_ => {
79
79
+
pb.log(
80
80
+
"Selecting",
81
81
+
&format!(
82
82
+
"FFMpeg encoder as {} ends with {}",
83
83
+
destination.pretty(),
84
84
+
destination.full_extension()
85
85
+
),
86
86
+
);
87
87
+
88
88
+
self.initial_canvas.load_fonts()?;
89
89
+
Box::new(self.setup_ffmpeg_encoder(width, height, destination)?)
90
90
+
}
91
91
+
})
92
92
+
}
93
93
+
}
94
94
+
95
95
+
96
96
+
// Because .extension() sucks
97
97
+
98
98
+
trait FullExtension {
99
99
+
fn full_extension(&self) -> &str;
100
100
+
}
101
101
+
102
102
+
impl FullExtension for PathBuf {
103
103
+
fn full_extension(&self) -> &str {
104
104
+
let filename = self
105
105
+
.file_name()
106
106
+
.and_then(|f| f.to_str())
107
107
+
.unwrap_or_default();
108
108
+
let parts: Vec<&str> = filename.split('.').collect();
109
109
+
if parts.len() <= 1 {
110
110
+
""
111
111
+
} else {
112
112
+
&filename[filename.find('.').unwrap()..]
113
113
+
}
114
114
+
}
115
115
+
}
+31
-97
src/video/encoding.rs
Reviewed
···
1
1
use super::Video;
2
2
+
use crate::Timestamp;
3
3
+
use crate::context::Context;
2
4
use crate::ui::{Log, Pretty};
3
5
use crate::video::encoders::Encoder;
4
4
-
use crate::video::encoders::vgv::VGVTranscodeMode;
5
5
-
use crate::video::engine::EngineOutput;
6
6
+
use crate::video::engine::{EngineControl, EngineController, EngineOutput};
6
7
use anyhow::{Result, anyhow};
7
8
use measure_time::debug_time;
8
9
use std::path::PathBuf;
9
10
use std::thread;
10
11
11
11
-
impl<AdditionalContext: Default> Video<AdditionalContext> {
12
12
+
impl<C: Default> Video<C> {
12
13
pub fn encode(
13
14
&mut self,
14
15
output_file: impl Into<PathBuf> + Clone,
15
16
) -> Result<std::time::Duration> {
17
17
+
let actual_ms_range = self.constrained_ms_range();
18
18
+
if actual_ms_range != self.total_ms_range() {
19
19
+
self.progress_bars.rendering.log(
20
20
+
"Constrained",
21
21
+
&Timestamp::from_ms_range(&actual_ms_range).pretty(),
22
22
+
);
23
23
+
}
24
24
+
25
25
+
self.encode_controlled(output_file, &move |ctx| {
26
26
+
if actual_ms_range.contains(&ctx.ms) {
27
27
+
EngineControl::Render
28
28
+
} else if ctx.ms > actual_ms_range.end {
29
29
+
EngineControl::Stop
30
30
+
} else {
31
31
+
EngineControl::Skip
32
32
+
}
33
33
+
})
34
34
+
}
35
35
+
36
36
+
pub fn encode_controlled(
37
37
+
&mut self,
38
38
+
output_file: impl Into<PathBuf> + Clone,
39
39
+
engine_controller: &EngineController<C>,
40
40
+
) -> Result<std::time::Duration> {
16
41
debug_time!("encode");
17
42
18
43
let encoder = self.setup_encoder(output_file.clone())?;
19
44
let encoder_name = encoder.name();
20
45
21
21
-
let time_taken = self.encode_with(encoder)?;
46
46
+
let time_taken = self.encode_with(encoder, engine_controller)?;
22
47
23
48
let _ = notify_rust::Notification::new()
24
49
.appname("Shapemaker")
···
32
57
Ok(time_taken)
33
58
}
34
59
35
35
-
fn setup_encoder(
36
36
-
&mut self,
37
37
-
output_path: impl Into<PathBuf>,
38
38
-
) -> Result<Box<dyn Encoder + Send>> {
39
39
-
let (width, height) =
40
40
-
self.initial_canvas.resolution_to_size_even(self.resolution);
41
41
-
42
42
-
let destination = output_path.into();
43
43
-
let pb = &self.progress_bars.encoding;
44
44
-
45
45
-
if destination.exists() {
46
46
-
std::fs::remove_file(&destination)?;
47
47
-
}
48
48
-
49
49
-
std::fs::create_dir_all(
50
50
-
&destination
51
51
-
.parent()
52
52
-
.expect("Given output file has no parent"),
53
53
-
)?;
54
54
-
55
55
-
Ok(match destination.full_extension() {
56
56
-
".vgv.html" => {
57
57
-
self.progress_bars.encoding.log(
58
58
-
"Selecting",
59
59
-
&format!(
60
60
-
"VGV encoder with HTML transcoding as {} ends with .vgv.html",
61
61
-
destination.pretty(),
62
62
-
),
63
63
-
);
64
64
-
65
65
-
Box::new(self.setup_vgv_encoder(
66
66
-
VGVTranscodeMode::ToHTML,
67
67
-
width as _,
68
68
-
height as _,
69
69
-
&self.initial_canvas,
70
70
-
destination,
71
71
-
)?)
72
72
-
}
73
73
-
".vgv" => {
74
74
-
self.progress_bars.encoding.log(
75
75
-
"Selecting",
76
76
-
&format!(
77
77
-
"VGV encoder as {} ends with .vgv (use .vgv.html for HTML transcoding)",
78
78
-
destination.pretty(),
79
79
-
),
80
80
-
);
81
81
-
82
82
-
Box::new(self.setup_vgv_encoder(
83
83
-
VGVTranscodeMode::None,
84
84
-
width as _,
85
85
-
height as _,
86
86
-
&self.initial_canvas,
87
87
-
destination,
88
88
-
)?)
89
89
-
}
90
90
-
_ => {
91
91
-
pb.log(
92
92
-
"Selecting",
93
93
-
&format!(
94
94
-
"FFMpeg encoder as {} ends with {}",
95
95
-
destination.pretty(),
96
96
-
destination.full_extension()
97
97
-
),
98
98
-
);
99
99
-
100
100
-
self.initial_canvas.load_fonts()?;
101
101
-
Box::new(self.setup_ffmpeg_encoder(width, height, destination)?)
102
102
-
}
103
103
-
})
104
104
-
}
105
105
-
106
60
pub fn encode_with(
107
61
&mut self,
108
62
mut encoder: Box<dyn Encoder + Send>,
63
63
+
engine_controller: &EngineController<C>,
109
64
) -> Result<std::time::Duration> {
110
65
debug_time!("encode_with");
111
66
···
146
101
Ok(time_taken)
147
102
});
148
103
149
149
-
self.render_with_overrides(tx)?;
104
104
+
self.render(tx, engine_controller)?;
150
105
151
106
let time_taken = encoder_thread
152
107
.join()
···
165
120
);
166
121
}
167
122
}
168
168
-
169
169
-
// Because .extension() sucks
170
170
-
171
171
-
trait FullExtension {
172
172
-
fn full_extension(&self) -> &str;
173
173
-
}
174
174
-
175
175
-
impl FullExtension for PathBuf {
176
176
-
fn full_extension(&self) -> &str {
177
177
-
let filename = self
178
178
-
.file_name()
179
179
-
.and_then(|f| f.to_str())
180
180
-
.unwrap_or_default();
181
181
-
let parts: Vec<&str> = filename.split('.').collect();
182
182
-
if parts.len() <= 1 {
183
183
-
""
184
184
-
} else {
185
185
-
&filename[filename.find('.').unwrap()..]
186
186
-
}
187
187
-
}
188
188
-
}
+33
-51
src/video/engine.rs
Reviewed
···
1
1
use super::{Video, context::Context};
2
2
+
use crate::SVGRenderable;
2
3
use crate::rendering::svg;
3
4
use crate::ui::{Log, Pretty};
4
4
-
use crate::{SVGRenderable, Timestamp};
5
5
use anyhow::Result;
6
6
use measure_time::debug_time;
7
7
use std::sync::mpsc::SyncSender;
8
8
+
9
9
+
pub type EngineController<C: Default> = dyn Fn(&Context<'_, C>) -> EngineControl;
8
10
9
11
/// What data is sent to the output by the rendering engine for each rendered frame
10
12
pub enum EngineOutput {
···
31
33
}
32
34
}
33
35
34
34
-
impl<AdditionalContext: Default> Video<AdditionalContext> {
36
36
+
impl<C: Default> Video<C> {
35
37
pub fn render(
36
38
&self,
37
39
output: SyncSender<EngineOutput>,
38
38
-
controller: impl Fn(&Context<AdditionalContext>) -> EngineControl,
40
40
+
controller: &EngineController<C>,
39
41
) -> Result<usize> {
40
42
debug_time!("render");
41
43
···
45
47
current_scene: None,
46
48
fps: self.fps,
47
49
syncdata: &self.syncdata,
48
48
-
extra: AdditionalContext::default(),
50
50
+
extra: C::default(),
49
51
inner_hooks: vec![],
50
52
audiofile: self.audiofile.clone(),
51
53
duration_override: self.duration_override,
···
89
91
90
92
pb.inc(1);
91
93
pb.set_message(match context.current_scene {
92
92
-
Some(ref scene) => format!("{}: {scene}", context.timestamp()),
94
94
+
Some(ref scene) => {
95
95
+
format!("{}: {scene}", context.timestamp())
96
96
+
}
93
97
None => format!("{}", context.timestamp()),
94
98
});
95
99
···
141
145
}
142
146
}
143
147
144
144
-
if !skip_rendering && context.frame() != previous_rendered_frame {
145
145
-
output.send(EngineOutput::Frame {
146
146
-
dimensions: (canvas.width(), canvas.height()),
147
147
-
svg: canvas.render_to_svg(
148
148
-
canvas.colormap.clone(),
149
149
-
canvas.cell_size,
150
150
-
canvas.object_sizes,
151
151
-
"",
152
152
-
)?,
153
153
-
})?;
148
148
+
if context.frame() != previous_rendered_frame {
149
149
+
if !skip_rendering {
150
150
+
output.send(EngineOutput::Frame {
151
151
+
dimensions: (canvas.width(), canvas.height()),
152
152
+
svg: canvas.render_to_svg(
153
153
+
canvas.colormap.clone(),
154
154
+
canvas.cell_size,
155
155
+
canvas.object_sizes,
156
156
+
"",
157
157
+
)?,
158
158
+
})?;
159
159
+
}
154
160
155
161
context.rendered_frames += 1;
156
162
···
179
185
Ok(context.rendered_frames)
180
186
}
181
187
182
182
-
pub fn render_single_frame(&self, frame_no: usize) -> Result<svg::Node> {
188
188
+
/// Render a single frame at the given frame number. Skip all hooks, expect for `render_ahead`
189
189
+
/// frames before the requested one.
190
190
+
pub fn render_frame(
191
191
+
&self,
192
192
+
frame_no: usize,
193
193
+
render_ahead: usize,
194
194
+
) -> Result<svg::Node> {
183
195
debug_time!("render_single_frame");
184
196
let (tx, rx) = std::sync::mpsc::sync_channel::<EngineOutput>(2);
185
197
186
186
-
self.render(tx, |ctx| {
198
198
+
let render_ahead_range = frame_no.saturating_sub(render_ahead)..frame_no;
199
199
+
200
200
+
self.render(tx, &move |ctx| {
187
201
if ctx.frame() == frame_no {
188
202
EngineControl::Finish
189
189
-
} else if ctx.frame() < frame_no {
203
203
+
} else if render_ahead_range.contains(&ctx.frame()) {
190
204
EngineControl::Walk
191
205
} else {
192
192
-
EngineControl::Stop
206
206
+
EngineControl::Skip
193
207
}
194
208
})?;
195
209
196
196
-
println!("Waiting for rendered frame...");
197
210
for output in rx.iter() {
198
211
match output {
199
212
EngineOutput::Finished => break,
···
204
217
return Err(anyhow::format_err!(
205
218
"Renderer did not output any non-empty frames"
206
219
));
207
207
-
}
208
208
-
209
209
-
pub fn render_everything(
210
210
-
&self,
211
211
-
output: SyncSender<EngineOutput>,
212
212
-
) -> Result<usize> {
213
213
-
self.render(output, |_| EngineControl::Render)
214
214
-
}
215
215
-
216
216
-
pub fn render_with_overrides(
217
217
-
&self,
218
218
-
output: SyncSender<EngineOutput>,
219
219
-
) -> Result<usize> {
220
220
-
let actual_ms_range = self.constrained_ms_range();
221
221
-
222
222
-
if actual_ms_range != self.total_ms_range() {
223
223
-
self.progress_bars.rendering.log(
224
224
-
"Constrained",
225
225
-
&Timestamp::from_ms_range(&actual_ms_range).pretty(),
226
226
-
);
227
227
-
}
228
228
-
229
229
-
self.render(output, |ctx| {
230
230
-
if actual_ms_range.contains(&ctx.ms) {
231
231
-
EngineControl::Render
232
232
-
} else if ctx.ms > actual_ms_range.end {
233
233
-
EngineControl::Stop
234
234
-
} else {
235
235
-
EngineControl::Skip
236
236
-
}
237
237
-
})
238
220
}
239
221
}
240
222
+2
-2
src/video/preview.html
Reviewed
···
16
16
<div id="frame_monitor"></div>
17
17
<div class="controls">
18
18
<button style="font-family: monospace" id="play_pause">|></button>
19
19
-
<input type="range" value="0" id="requested_frame" min="1" max="300" />
19
19
+
<input type="range" value="0" id="requested_frame" min="1" max="%frames_count%" />
20
20
<code id="requested_frame_number"></code>
21
21
</div>
22
22
<script type="module">
···
41
41
}
42
42
}
43
43
44
44
-
requested_frame.oninput = debounce(10, ({ target }) => {
44
44
+
requested_frame.oninput = debounce(200, ({ target }) => {
45
45
render(target.valueAsNumber)
46
46
})
47
47
+9
-5
src/video/server.rs
Reviewed
···
1
1
-
use crate::Video;
1
1
+
use crate::{Video, ui::Log};
2
2
use axum::{Router, extract::Path, response::Html, routing};
3
3
use std::sync::Arc;
4
4
···
12
12
pub fn new<C: 'static + Default>(video: Arc<Video<C>>) -> Self {
13
13
let _ = video.progress.clear();
14
14
15
15
+
let total_frames_count = video.ms_to_frames(video.total_duration_ms());
16
16
+
15
17
let router = Router::new()
16
16
-
.route("/", routing::get(async || Html(PREVIEW_HTML)))
17
17
-
.route("/frame/{number_dot_svg}",
18
18
-
routing::get(async move |Path(number_dot_svg): Path<String>| {
18
18
+
.route("/", routing::get(async move || Html(PREVIEW_HTML.replace("%frames_count%", &total_frames_count.to_string()))))
19
19
+
.route("/frame/{number_dot_svg}", routing::get(async move |Path(number_dot_svg): Path<String>| {
19
20
let number: usize = number_dot_svg
20
21
.strip_suffix(".svg")
21
22
.expect("Expecting /frame/{number}.svg, didn't find .svg at the end")
···
25
26
println!("");
26
27
println!("Frame number requested: {number}");
27
28
28
28
-
match video.render_single_frame(number) {
29
29
+
match video.render_frame(number, 500) {
29
30
// Ok((timecode, svg)) => svg.to_string().replace(
30
31
// "</svg>",
31
32
// &format!(r#"<meta name="shapemaker:timecode" content="{timecode}" /></svg>"#)
···
51
52
52
53
impl<C: 'static + Default> Video<C> {
53
54
pub async fn serve(self, address: &str) {
55
55
+
self.progress_bars
56
56
+
.loading
57
57
+
.log_cyan("Listening", &format!("on {address}"));
54
58
VideoServer::new(Arc::new(self)).start(address).await;
55
59
}
56
60
}
+1
src/video/video.rs
Reviewed
···
28
28
}
29
29
}
30
30
31
31
+
#[derive(PartialEq, PartialOrd, Eq, Ord)]
31
32
pub struct Timestamp(pub usize);
32
33
33
34
impl Timestamp {