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
✨ Add VGV encoding support
author
Gwenn Le Bihan
date
8 months ago
(Oct 25, 2025, 9:53 PM +0200)
commit
c9c84dbc
c9c84dbcdc88ca626ca33d4c1bf96e203ab53603
parent
fbd6b1ea
fbd6b1eaf5eb5faebd25ba23ce77736e4fe5ac8d
+172
-63
12 changed files
Expand all
Collapse all
Unified
Split
.gitignore
Cargo.lock
Cargo.toml
examples
schedule-hell
Cargo.toml
src
main.rs
src
lib.rs
main.rs
rendering
svg.rs
video
encoding.rs
engine.rs
mod.rs
server.rs
+1
.gitignore
Reviewed
···
28
28
framedump.png
29
29
framedump.svg
30
30
results.csv
31
31
+
examples/schedule-hell/schedule-hell.vgv
+34
-38
Cargo.lock
Reviewed
···
1013
1013
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
1014
1014
1015
1015
[[package]]
1016
1016
+
name = "diff-match-patch-rs"
1017
1017
+
version = "0.5.1"
1018
1018
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1019
1019
+
checksum = "f1e836eb966678daed8789e698dbdb1016a260c3cdaca6172585e13580c4e4be"
1020
1020
+
dependencies = [
1021
1021
+
"chrono",
1022
1022
+
"percent-encoding",
1023
1023
+
]
1024
1024
+
1025
1025
+
[[package]]
1016
1026
name = "digest"
1017
1027
version = "0.8.1"
1018
1028
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2625
2635
"js-sys",
2626
2636
"log",
2627
2637
"wasm-bindgen",
2628
2628
-
"windows-core 0.62.2",
2638
2638
+
"windows-core",
2629
2639
]
2630
2640
2631
2641
[[package]]
···
4590
4600
"toml 0.9.8",
4591
4601
"tungstenite",
4592
4602
"url",
4603
4603
+
"vgv",
4593
4604
"wasm-bindgen",
4594
4605
"watchexec",
4595
4606
"watchexec-events",
···
5454
5465
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
5455
5466
5456
5467
[[package]]
5468
5468
+
name = "vgv"
5469
5469
+
version = "0.1.0"
5470
5470
+
source = "git+https://github.com/gwennlbh/vgvf#fbde8ac4efde5f3c95548f82d6e09424800aea8a"
5471
5471
+
dependencies = [
5472
5472
+
"anyhow",
5473
5473
+
"base64",
5474
5474
+
"diff-match-patch-rs",
5475
5475
+
"pico-args",
5476
5476
+
"resvg",
5477
5477
+
"serde",
5478
5478
+
"serde_json",
5479
5479
+
"tiny-skia",
5480
5480
+
"usvg",
5481
5481
+
]
5482
5482
+
5483
5483
+
[[package]]
5457
5484
name = "vst3-com"
5458
5485
version = "0.1.0"
5459
5486
source = "git+https://github.com/robbert-vdh/vst3-sys.git?branch=fix%2Fdrop-box-from-raw#b3ff4d775940f5b476b9d1cca02a90e07e1922a2"
···
5726
5753
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
5727
5754
dependencies = [
5728
5755
"windows-collections",
5729
5729
-
"windows-core 0.61.2",
5756
5756
+
"windows-core",
5730
5757
"windows-future",
5731
5758
"windows-link 0.1.3",
5732
5759
"windows-numerics",
···
5738
5765
source = "registry+https://github.com/rust-lang/crates.io-index"
5739
5766
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
5740
5767
dependencies = [
5741
5741
-
"windows-core 0.61.2",
5768
5768
+
"windows-core",
5742
5769
]
5743
5770
5744
5771
[[package]]
···
5750
5777
"windows-implement",
5751
5778
"windows-interface",
5752
5779
"windows-link 0.1.3",
5753
5753
-
"windows-result 0.3.4",
5754
5754
-
"windows-strings 0.4.2",
5755
5755
-
]
5756
5756
-
5757
5757
-
[[package]]
5758
5758
-
name = "windows-core"
5759
5759
-
version = "0.62.2"
5760
5760
-
source = "registry+https://github.com/rust-lang/crates.io-index"
5761
5761
-
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
5762
5762
-
dependencies = [
5763
5763
-
"windows-implement",
5764
5764
-
"windows-interface",
5765
5765
-
"windows-link 0.2.1",
5766
5766
-
"windows-result 0.4.1",
5767
5767
-
"windows-strings 0.5.1",
5780
5780
+
"windows-result",
5781
5781
+
"windows-strings",
5768
5782
]
5769
5783
5770
5784
[[package]]
···
5773
5787
source = "registry+https://github.com/rust-lang/crates.io-index"
5774
5788
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
5775
5789
dependencies = [
5776
5776
-
"windows-core 0.61.2",
5790
5790
+
"windows-core",
5777
5791
"windows-link 0.1.3",
5778
5792
"windows-threading",
5779
5793
]
···
5818
5832
source = "registry+https://github.com/rust-lang/crates.io-index"
5819
5833
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
5820
5834
dependencies = [
5821
5821
-
"windows-core 0.61.2",
5835
5835
+
"windows-core",
5822
5836
"windows-link 0.1.3",
5823
5837
]
5824
5838
···
5832
5846
]
5833
5847
5834
5848
[[package]]
5835
5835
-
name = "windows-result"
5836
5836
-
version = "0.4.1"
5837
5837
-
source = "registry+https://github.com/rust-lang/crates.io-index"
5838
5838
-
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
5839
5839
-
dependencies = [
5840
5840
-
"windows-link 0.2.1",
5841
5841
-
]
5842
5842
-
5843
5843
-
[[package]]
5844
5849
name = "windows-strings"
5845
5850
version = "0.4.2"
5846
5851
source = "registry+https://github.com/rust-lang/crates.io-index"
5847
5852
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
5848
5853
dependencies = [
5849
5854
"windows-link 0.1.3",
5850
5850
-
]
5851
5851
-
5852
5852
-
[[package]]
5853
5853
-
name = "windows-strings"
5854
5854
-
version = "0.5.1"
5855
5855
-
source = "registry+https://github.com/rust-lang/crates.io-index"
5856
5856
-
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
5857
5857
-
dependencies = [
5858
5858
-
"windows-link 0.2.1",
5859
5855
]
5860
5856
5861
5857
[[package]]
+3
-2
Cargo.toml
Reviewed
···
30
30
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
31
31
32
32
[features]
33
33
-
default = ["cli", "vst", "mp4", "video-server"]
33
33
+
default = ["cli", "vst", "video", "video-server"]
34
34
vst = [
35
35
"cli",
36
36
"rand/thread_rng",
···
48
48
"dep:miette",
49
49
]
50
50
web = ["dep:wasm-bindgen", "dep:web-sys"]
51
51
-
mp4 = ["dep:env_logger"]
51
51
+
video = ["dep:env_logger", "dep:vgv"]
52
52
video-server = ["dep:axum"]
53
53
54
54
[dependencies]
···
107
107
tungstenite = { version = "0.28.0", optional = true }
108
108
axum = { version = "0.8.6", optional = true, features = ["json"] }
109
109
quick-xml = "0.38.3"
110
110
+
vgv = { git = "https://github.com/gwennlbh/vgvf", version = "0.1.0", optional = true}
110
111
111
112
112
113
[dev-dependencies]
+1
-1
examples/schedule-hell/Cargo.toml
Reviewed
···
8
8
itertools = "0.14.0"
9
9
pico-args = { version = "0.5.0", features = ["combined-flags", "eq-separator"] }
10
10
rand = "0.9.0"
11
11
-
shapemaker = { path = "../..", features = ["mp4"] }
11
11
+
shapemaker = { path = "../..", features = ["video"] }
12
12
tokio = "1.48.0"
+6
-1
examples/schedule-hell/src/main.rs
Reviewed
···
250
250
Ok(())
251
251
});
252
252
253
253
-
if args.contains("--serve") {
253
253
+
if args.contains("--vgv") {
254
254
+
video.encode_to_vgv(
255
255
+
args.free_from_str()
256
256
+
.unwrap_or(String::from("schedule-hell.vgv")),
257
257
+
);
258
258
+
} else if args.contains("--serve") {
254
259
video.serve("localhost:8000").await;
255
260
} else {
256
261
video.encode(
+2
-2
src/lib.rs
Reviewed
···
4
4
let mut features = vec![];
5
5
#[cfg(feature = "vst")]
6
6
features.push("vst");
7
7
-
#[cfg(feature = "mp4")]
8
8
-
features.push("mp4");
7
7
+
#[cfg(feature = "video")]
8
8
+
features.push("video");
9
9
#[cfg(feature = "cli")]
10
10
features.push("cli");
11
11
#[cfg(feature = "web")]
+5
-5
src/main.rs
Reviewed
···
2
2
use shapemaker::*;
3
3
4
4
#[cfg(feature = "vst")]
5
5
-
#[cfg(feature = "mp4")]
5
5
+
#[cfg(feature = "video")]
6
6
use env_logger;
7
7
use measure_time::debug_time;
8
8
···
23
23
#[tokio::main]
24
24
pub async fn main() -> Result<()> {
25
25
#[cfg(feature = "vst")]
26
26
-
#[cfg(feature = "mp4")]
26
26
+
#[cfg(feature = "video")]
27
27
env_logger::init();
28
28
run(cli::cli_args()).await
29
29
}
···
89
89
Ok(probe.say("ping hehe")?)
90
90
}
91
91
92
92
-
#[cfg(all(feature = "cli", not(feature = "mp4")))]
92
92
+
#[cfg(all(feature = "cli", not(feature = "video")))]
93
93
fn run_video(_args: cli::Args) -> Result<()> {
94
94
println!(
95
95
-
"Video rendering is disabled. Enable the mp4 feature to render videos."
95
95
+
"Video rendering is disabled. Enable the video feature to render videos."
96
96
);
97
97
Ok(())
98
98
}
99
99
100
100
-
#[cfg(all(feature = "cli", feature = "mp4"))]
100
100
+
#[cfg(all(feature = "cli", feature = "video"))]
101
101
fn run_video(args: cli::Args) -> Result<()> {
102
102
use shapemaker::fonts::FontOptions;
103
103
+23
-2
src/rendering/svg.rs
Reviewed
···
1
1
use std::{collections::HashMap, fmt::Display};
2
2
3
3
use itertools::Itertools;
4
4
+
use measure_time::debug_time;
4
5
5
6
use crate::{Color, ColorMapping, Point, Region};
6
7
···
25
26
}
26
27
}
27
28
28
28
-
pub fn tag(tag: &str) -> Element {
29
29
-
Element::new(tag)
29
29
+
pub fn tag(tag_name: &str) -> Element {
30
30
+
Element::new(tag_name)
31
31
+
}
32
32
+
33
33
+
pub fn node(tag_name: &str) -> Node {
34
34
+
tag(tag_name).node()
35
35
+
}
36
36
+
37
37
+
impl Node {
38
38
+
pub fn is_empty(&self) -> bool {
39
39
+
match self {
40
40
+
Node::Element(e) => e.is_empty(),
41
41
+
Node::Text(t) => t.is_empty(),
42
42
+
Node::SVG(s) => s.is_empty(),
43
43
+
}
44
44
+
}
30
45
}
31
46
32
47
impl Element {
···
148
163
children: vec![Node::Element(self)],
149
164
}
150
165
}
166
166
+
167
167
+
pub fn is_empty(&self) -> bool {
168
168
+
self.children.is_empty()
169
169
+
}
151
170
}
152
171
153
172
pub enum PathInstruction {
···
276
295
277
296
impl Display for Node {
278
297
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298
298
+
debug_time!("svg::Node::fmt");
299
299
+
279
300
match self {
280
301
Node::Text(text) => write!(f, "{}", quick_xml::escape::escape(text)),
281
302
Node::SVG(svg) => write!(f, "{}", svg),
+86
-2
src/video/encoding.rs
Reviewed
···
1
1
use super::{hooks::milliseconds_to_timestamp, Video};
2
2
+
use crate::rendering::svg;
2
3
use crate::Canvas;
3
4
use anyhow::Result;
5
5
+
use itertools::Itertools;
4
6
use measure_time::debug_time;
5
7
use std::fs::File;
6
8
use std::io::Write;
···
9
11
use std::{fs::create_dir_all, path::PathBuf};
10
12
11
13
impl<AdditionalContext: Default> Video<AdditionalContext> {
14
14
+
pub fn encode_to_vgv(
15
15
+
&mut self,
16
16
+
output_file: impl Into<PathBuf>,
17
17
+
) -> Result<()> {
18
18
+
debug_time!("encode_to_vgv");
19
19
+
let output_file: PathBuf = output_file.into();
20
20
+
21
21
+
if output_file.exists() {
22
22
+
std::fs::remove_file(&output_file)?;
23
23
+
}
24
24
+
25
25
+
create_dir_all(
26
26
+
&output_file
27
27
+
.parent()
28
28
+
.expect("Given output file has no parent"),
29
29
+
)?;
30
30
+
31
31
+
let mut file = File::create(&output_file)?;
32
32
+
33
33
+
self.progress_bar.set_position(0);
34
34
+
self.progress_bar.set_prefix("Rendering");
35
35
+
self.progress_bar.set_message("");
36
36
+
37
37
+
self.initial_canvas.load_fonts()?;
38
38
+
let initial_canvas = self.initial_canvas.clone();
39
39
+
let resolution = self.resolution;
40
40
+
let (width, height) = initial_canvas.resolution_to_size_even(resolution);
41
41
+
42
42
+
let (tx, rx) =
43
43
+
std::sync::mpsc::sync_channel::<(Duration, svg::Node)>(1_000);
44
44
+
45
45
+
let pb = self.progress_bar.clone();
46
46
+
47
47
+
let mut vgv_encoder = vgv::Encoder::new(
48
48
+
vgv::InitializationParameters {
49
49
+
w: width as _,
50
50
+
h: height as _,
51
51
+
d: (1000.0 / self.fps as f64) as _,
52
52
+
bg: initial_canvas
53
53
+
.background
54
54
+
.unwrap_or_default()
55
55
+
.render(&initial_canvas.colormap),
56
56
+
},
57
57
+
format!(
58
58
+
r#"width={w} height={h} viewBox="-{pad} -{pad} {w} {h}""#,
59
59
+
w = initial_canvas.width(),
60
60
+
h = initial_canvas.height(),
61
61
+
pad = initial_canvas.canvas_outer_padding
62
62
+
),
63
63
+
);
64
64
+
65
65
+
let vgv_thread = thread::spawn(move || {
66
66
+
for (time, svg) in rx.iter() {
67
67
+
if svg.is_empty() {
68
68
+
break;
69
69
+
}
70
70
+
71
71
+
vgv_encoder.encode_svg(match svg {
72
72
+
svg::Node::Text(text) => text,
73
73
+
svg::Node::SVG(svg) => svg,
74
74
+
svg::Node::Element(element) => element
75
75
+
.children
76
76
+
.iter()
77
77
+
.map(|child| child.to_string())
78
78
+
.join(""),
79
79
+
});
80
80
+
81
81
+
pb.set_position(time.as_millis() as _);
82
82
+
pb.set_message(milliseconds_to_timestamp(time.as_millis() as _));
83
83
+
}
84
84
+
85
85
+
vgv_encoder.dump(&mut file);
86
86
+
});
87
87
+
88
88
+
self.render_all_frames(tx)?;
89
89
+
90
90
+
vgv_thread.join().expect("VGV thread panicked");
91
91
+
92
92
+
Ok(())
93
93
+
}
94
94
+
12
95
fn setup_encoder(
13
96
&mut self,
14
97
output_path: impl Into<PathBuf>,
···
75
158
let initial_canvas = self.initial_canvas.clone();
76
159
let resolution = self.resolution;
77
160
78
78
-
let (tx, rx) = std::sync::mpsc::sync_channel::<(Duration, String)>(1_000);
161
161
+
let (tx, rx) =
162
162
+
std::sync::mpsc::sync_channel::<(Duration, svg::Node)>(1_000);
79
163
80
164
let pb = self.progress_bar.clone();
81
165
···
90
174
resolution,
91
175
time,
92
176
&initial_canvas,
93
93
-
&svg,
177
177
+
&svg.to_string(),
94
178
)
95
179
.unwrap();
96
180
+9
-8
src/video/engine.rs
Reviewed
···
1
1
use super::{context::Context, hooks::milliseconds_to_timestamp, Video};
2
2
-
use crate::rendering::stringify_svg;
2
2
+
use crate::rendering::svg;
3
3
use crate::{Canvas, SVGRenderable};
4
4
use anyhow::Result;
5
5
use measure_time::debug_time;
···
9
9
impl<AdditionalContext: Default> Video<AdditionalContext> {
10
10
pub fn render(
11
11
&self,
12
12
-
output: SyncSender<(Duration, String)>,
12
12
+
output: SyncSender<(Duration, svg::Node)>,
13
13
controller: impl Fn(&Context<AdditionalContext>) -> EngineControl,
14
14
) -> Result<usize> {
15
15
debug_time!("render");
···
126
126
if !skip_rendering && context.frame != previous_rendered_frame {
127
127
output.send((
128
128
Duration::from_millis(context.ms as _),
129
129
-
stringify_svg(canvas.render_to_svg(
129
129
+
canvas.render_to_svg(
130
130
canvas.colormap.clone(),
131
131
canvas.cell_size,
132
132
canvas.object_sizes,
133
133
"",
134
134
-
)?),
134
134
+
)?,
135
135
))?;
136
136
137
137
rendered_frames_count += 1;
···
149
149
}
150
150
}
151
151
152
152
-
output.send((Duration::from_millis(context.ms as _), "".to_string()))?;
152
152
+
output
153
153
+
.send((Duration::from_millis(context.ms as _), svg::node("svg")))?;
153
154
154
155
println!("Rendered {rendered_frames_count} frames");
155
156
Ok(rendered_frames_count)
···
158
159
pub fn render_single_frame(
159
160
&self,
160
161
frame_no: usize,
161
161
-
) -> Result<(Duration, String)> {
162
162
+
) -> Result<(Duration, svg::Node)> {
162
163
debug_time!("render_single_frame");
163
163
-
let (tx, rx) = std::sync::mpsc::sync_channel::<(Duration, String)>(2);
164
164
+
let (tx, rx) = std::sync::mpsc::sync_channel::<(Duration, svg::Node)>(2);
164
165
165
166
self.render(tx, |ctx| {
166
167
if ctx.frame == frame_no {
···
188
189
189
190
pub fn render_all_frames(
190
191
&self,
191
191
-
output: SyncSender<(Duration, String)>,
192
192
+
output: SyncSender<(Duration, svg::Node)>,
192
193
) -> Result<usize> {
193
194
self.render(output, |_| EngineControl::Render)
194
195
}
+1
-1
src/video/mod.rs
Reviewed
···
3
3
pub mod engine;
4
4
pub mod hooks;
5
5
6
6
-
#[cfg(feature = "mp4")]
6
6
+
#[cfg(feature = "video")]
7
7
pub mod encoding;
8
8
9
9
#[cfg(feature = "video-server")]
+1
-1
src/video/server.rs
Reviewed
···
26
26
println!("Frame number requested: {number}");
27
27
28
28
match video.render_single_frame(number) {
29
29
-
Ok((timecode, svg)) => svg.replace(
29
29
+
Ok((timecode, svg)) => svg.to_string().replace(
30
30
"</svg>",
31
31
&format!(r#"<meta name="shapemaker:timecode" content="{timecode:?}" /></svg>"#)
32
32
),