Skip to content

Commit 7e4250d

Browse files
canduxCommanderStormpre-commit-ci[bot]claude
authored
Add PMTiles compression support to the Rust MVT-to-MLT converter (#1498)
I'm looking into switching my project [wogibt.es](https://wogibt.es/) to MLT. When I first ran `mlt convert`, I was surprised that the output PMTiles archive was much larger than the input. The MVT tiles were gzip-compressed, while the generated MLT tiles were not. This PR adds compression support and preserves compatible input compression by default. ## Changes - Add `--tile-compression auto|gzip|none`. - Use `auto` by default. - Preserve uncompressed, gzip, Brotli, and Zstandard input compression in auto mode. - Require an explicit `gzip` or `none` override for zlib input because PMTiles v3 cannot represent zlib compression. - Write the selected compression to the PMTiles header and metadata. - Reject explicit compression options for unsupported input/output combinations. - Report raw payload and complete archive sizes separately. - Add documentation and tests for compression handling and round trips. Brotli and Zstandard are intentionally only preserved through auto-detection and are not exposed as explicit CLI options. Common web PMTiles clients require custom decompression support for these codecs, so exposing them directly could make it easy to produce archives that clients cannot read. If exposing every PMTiles-supported compression mode is preferred, I can add explicit `brotli` and `zstd` options as well. Example output: ```text input.mvt.pmtiles -> output.mlt.pmtiles (pmtiles): converted 5 tiles (5 unique encoded, 0 cache hits) in 740.9ms size raw/archive: MVT(gzip) 813.7kB/459.8kB -> MLT(gzip) 460.3kB/357.0kB ``` ## AI Assistance This pull request was developed with assistance from OpenAI Codex. The changes were reviewed and tested by me. --------- Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8251ad0 commit 7e4250d

5 files changed

Lines changed: 278 additions & 64 deletions

File tree

rust/mlt/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,25 @@ The `mlt` binary provides several commands for working with MLT files:
66

77
* **`dump`** - Parse an MLT file and dump raw layer data without decoding
88
* **`decode`** - Parse an MLT file, decode all layers, and dump the result (supports text and `GeoJSON` output)
9+
* **`convert`** - Convert MVT or MLT tile files and MVT `.mbtiles`/`.pmtiles` archives to MLT
910
* **`ui`** - Interactive terminal visualizer for MLT files
1011

12+
### Format conversion
13+
14+
Convert an MVT archive or files to MLT:
15+
16+
```bash
17+
mlt convert input.mvt.pmtiles output.mlt.pmtiles
18+
```
19+
20+
The conversion summary reports unique, decompressed tile payloads as such:
21+
22+
```text
23+
input.mvt.pmtiles -> output.mlt.pmtiles (pmtiles):
24+
converted 5 tiles (5 unique encoded, 0 cache hits) in 740.9ms
25+
size raw/archive: MVT(gzip) 813.7kB/459.8kB -> MLT(gzip) 460.3kB/357.0kB
26+
```
27+
1128
### Visualizer
1229

1330
The visualizer command provides an interactive terminal-based UI for exploring MLT files:

rust/mlt/src/convert/common.rs

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use indicatif::{ProgressBar, ProgressStyle};
66
use martin_tile_utils::Encoding;
77
use mlt_core::encoder::EncoderConfig;
88
use moka::sync::Cache;
9-
use pmtiles::{PmTilesWriter, TileCoord};
9+
use pmtiles::{Compression, PmTilesWriter, TileCoord};
1010
use size_format::SizeFormatterSI;
1111
use xxhash_rust::xxh3::Xxh3Builder;
1212

@@ -50,7 +50,7 @@ pub const MAX_TILE_CACHE_TRACK_SIZE_BYTES: usize = 1024;
5050
const PROGRESS_BAR_TEMPLATE: &str = " {bar:40.cyan/blue} {pos}/{len} tiles [{rate}, eta {eta}]";
5151

5252
/// The encode dedup cache, keyed on the raw (small) tile bytes.
53-
pub type EncodeCache = Cache<Vec<u8>, Bytes, Xxh3Builder>;
53+
pub type EncodeCache = Cache<Vec<u8>, (Bytes, u64), Xxh3Builder>;
5454

5555
pub fn make_progress_bar(total: u64) -> ProgressBar {
5656
let bar = ProgressBar::new(total);
@@ -68,25 +68,26 @@ pub fn make_progress_bar(total: u64) -> ProgressBar {
6868
pub fn make_encode_cache() -> EncodeCache {
6969
Cache::builder()
7070
.max_capacity(ENCODE_CACHE_BYTES)
71-
.weigher(|_, v: &Bytes| u32::try_from(v.len()).unwrap_or(u32::MAX))
71+
.weigher(|_, v: &(Bytes, u64)| u32::try_from(v.0.len()).unwrap_or(u32::MAX))
7272
.build_with_hasher(Xxh3Builder::default())
7373
}
7474

75-
/// Encode one source tile MVT MLT, deduplicating through `cache`.
75+
/// Encode one source tile MVT -> MLT, deduplicating through `cache`.
7676
///
77-
/// Only small tiles (ocean, empty land, ...) actually repeat across a tileset;
78-
/// big city tiles are essentially unique, so tiles over
79-
/// [`MAX_TILE_CACHE_TRACK_SIZE_BYTES`] skip the cache (any rare repeat still
80-
/// dedups when the container is written). Returns the encoded bytes and whether
81-
/// they came from the cache. Mirrors `files.rs` and the mbtiles transcoder.
77+
/// Returns the encoded bytes, the raw MVT size, and whether the result came from the cache.
78+
///
79+
/// Only small tiles (ocean, empty land, ...) actually repeat across a tileset.
80+
/// Big city tiles are essentially unique, so tiles over [`MAX_TILE_CACHE_TRACK_SIZE_BYTES`] skip the cache.
81+
/// Any rare repeat still dedups when the container is written.
8282
pub fn encode_tile(
8383
cache: &EncodeCache,
8484
data: &[u8],
8585
encoding: Encoding,
8686
cfg: EncoderConfig,
87-
) -> AnyResult<(Bytes, bool)> {
87+
) -> AnyResult<(Bytes, u64, bool)> {
8888
if data.len() > MAX_TILE_CACHE_TRACK_SIZE_BYTES {
89-
return Ok((encode_one(data.to_vec(), encoding, cfg)?, false));
89+
let (encoded, raw_mvt_size) = encode_one(data.to_vec(), encoding, cfg)?;
90+
return Ok((encoded, raw_mvt_size, false));
9091
}
9192
let mut hit = true;
9293
let encoded = cache
@@ -95,7 +96,7 @@ pub fn encode_tile(
9596
encode_one(data.to_vec(), encoding, cfg)
9697
})
9798
.map_err(|e| anyhow!("{e}"))?;
98-
Ok((encoded, hit))
99+
Ok((encoded.0, encoded.1, hit))
99100
}
100101

101102
/// Running totals for a container-to-container conversion, matching the
@@ -105,32 +106,46 @@ pub struct TileStats {
105106
written: u64,
106107
cache_hits: u64,
107108
cache_encoded: u64,
108-
bytes_in: u64,
109-
bytes_out: u64,
109+
raw_mvt_bytes: u64,
110+
raw_mlt_bytes: u64,
110111
}
111112

112113
impl TileStats {
113-
pub fn record(&mut self, encoded_len: u64, bytes_in: u64, hit: bool) {
114+
pub fn record(&mut self, encoded_len: u64, raw_mvt_size: u64, hit: bool) {
114115
self.written += 1;
115116
if hit {
116117
self.cache_hits += 1;
117118
} else {
118119
self.cache_encoded += 1;
119-
self.bytes_in += bytes_in;
120-
self.bytes_out += encoded_len;
120+
self.raw_mvt_bytes += raw_mvt_size;
121+
self.raw_mlt_bytes += encoded_len;
121122
}
122123
}
123124

124-
pub fn print_summary(&self, start: Instant) {
125+
pub fn print_summary(
126+
&self,
127+
start: Instant,
128+
input_archive_size: u64,
129+
output_archive_size: u64,
130+
source_encoding: Encoding,
131+
tile_compression: Compression,
132+
) {
133+
let source_encoding = source_encoding.compression().unwrap_or("none");
134+
let tile_compression = tile_compression.content_encoding().unwrap_or("none");
125135
eprintln!(
126-
" converted {} tiles ({} unique encoded, {} cache hits, {:.1}B -> {:.1}B) in {:.1?}",
136+
" converted {} tiles ({} unique encoded, {} cache hits) in {:.1?}",
127137
self.written,
128138
self.cache_encoded,
129139
self.cache_hits,
130-
SizeFormatterSI::new(self.bytes_in),
131-
SizeFormatterSI::new(self.bytes_out),
132140
start.elapsed(),
133141
);
142+
eprintln!(
143+
" size raw/archive: MVT({source_encoding}) {:.1}B/{:.1}B -> MLT({tile_compression}) {:.1}B/{:.1}B",
144+
SizeFormatterSI::new(self.raw_mvt_bytes),
145+
SizeFormatterSI::new(input_archive_size),
146+
SizeFormatterSI::new(self.raw_mlt_bytes),
147+
SizeFormatterSI::new(output_archive_size),
148+
);
134149
}
135150
}
136151

@@ -139,6 +154,6 @@ impl TileStats {
139154
pub struct EncodedTile {
140155
pub coord: TileCoord,
141156
pub data: Bytes,
142-
pub bytes_in: u64,
157+
pub raw_mvt_size: u64,
143158
pub hit: bool,
144159
}

rust/mlt/src/convert/from_mbtiles.rs

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ use futures::StreamExt;
99
use martin_tile_utils::{Encoding, Format};
1010
use mbtiles::{MbtType, Mbtiles, MbtilesTranscoder, Metadata};
1111
use mlt_core::encoder::EncoderConfig;
12-
use pmtiles::{PmTilesWriter, TileCoord, TileType};
12+
use pmtiles::{Compression, PmTilesWriter, TileCoord, TileType};
1313
use size_format::SizeFormatterSI;
1414
use usize_cast::FromUsize as _;
1515

1616
use super::common::{
1717
ENCODE_CACHE_BYTES, EncodedTile, MAX_TILE_CACHE_TRACK_SIZE_BYTES, PmTilesGeography, TileStats,
1818
encode_tile, make_encode_cache, make_progress_bar,
1919
};
20-
use super::{ContainerFormat, MbtFormat, encode_one};
20+
use super::{ContainerFormat, MbtFormat, encode_one, update_mlt_pmtiles_metadata};
2121

2222
fn geography_from_metadata(metadata: &Metadata) -> PmTilesGeography {
2323
let tilejson = &metadata.tilejson;
@@ -35,12 +35,15 @@ pub async fn convert(
3535
output: (&Path, ContainerFormat),
3636
cfg: EncoderConfig,
3737
mbtiles_format: Option<MbtFormat>,
38+
tile_compression: Compression,
3839
) -> AnyResult<()> {
3940
match output {
4041
(output, ContainerFormat::Mbtiles) => {
4142
convert_mbtiles_to_mbtiles(input, output, mbtiles_format, cfg).await
4243
}
43-
(output, ContainerFormat::Pmtiles) => convert_mbtiles_to_pmtiles(input, output, cfg).await,
44+
(output, ContainerFormat::Pmtiles) => {
45+
convert_mbtiles_to_pmtiles(input, output, cfg, tile_compression).await
46+
}
4447
(output, ContainerFormat::Files) => bail!(
4548
"Output must be either an .mbtiles or a .pmtiles file when input is an .mbtiles file, got: {}",
4649
output.display()
@@ -108,6 +111,7 @@ async fn convert_mbtiles_to_mbtiles(
108111
.bytes_in
109112
.fetch_add(u64::from_usize(data.len()), Ordering::Relaxed);
110113
let result = encode_one(data, encoding, cfg)
114+
.map(|(data, _raw_mvt_size)| data)
111115
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() });
112116
if let Ok(ref encoded) = result {
113117
sizes_ref
@@ -155,24 +159,28 @@ async fn convert_mbtiles_to_pmtiles(
155159
input: &Path,
156160
output: &Path,
157161
cfg: EncoderConfig,
162+
tile_compression: Compression,
158163
) -> AnyResult<()> {
159164
// FIXME: add a fastpath for normalised schemas. We don't need to cache them
160-
let (encoding, _, mut metadata, total) = get_metadata(input).await?;
165+
let (encoding, _, metadata, total) = get_metadata(input).await?;
166+
let input_archive_size = std::fs::metadata(input)?.len();
161167

162168
eprintln!("{} -> {} (pmtiles):", input.display(), output.display());
163169

164170
let start = Instant::now();
165171
let bar = make_progress_bar(total);
166172

167173
let geography = geography_from_metadata(&metadata);
168-
metadata.tilejson.other.insert(
169-
"format".into(),
170-
serde_json::Value::String(Format::Mlt.metadata_format_value().into()),
171-
);
172174
let file = std::fs::File::create(output)?;
173-
let metadata_str = serde_json::to_string(&metadata.tilejson)?;
175+
let mut metadata_json = serde_json::to_value(&metadata.tilejson)?;
176+
let metadata_obj = metadata_json
177+
.as_object_mut()
178+
.ok_or_else(|| anyhow!("MBTiles metadata must serialize to a JSON object"))?;
179+
update_mlt_pmtiles_metadata(metadata_obj, tile_compression);
180+
let metadata_str = serde_json::to_string(&metadata_json)?;
174181
let mut stream_writer = geography
175182
.apply(PmTilesWriter::new(TileType::Mlt))
183+
.tile_compression(tile_compression)
176184
.metadata(&metadata_str)
177185
.create(file)?;
178186

@@ -199,12 +207,11 @@ async fn convert_mbtiles_to_pmtiles(
199207
.map(|(coord, data)| {
200208
let cache = cache.clone();
201209
tokio::task::spawn_blocking(move || -> AnyResult<EncodedTile> {
202-
let bytes_in = data.len() as u64;
203-
let (data, hit) = encode_tile(&cache, &data, encoding, cfg)?;
210+
let (data, raw_mvt_size, hit) = encode_tile(&cache, &data, encoding, cfg)?;
204211
Ok(EncodedTile {
205212
coord,
206213
data,
207-
bytes_in,
214+
raw_mvt_size,
208215
hit,
209216
})
210217
})
@@ -217,17 +224,24 @@ async fn convert_mbtiles_to_pmtiles(
217224
let EncodedTile {
218225
coord,
219226
data,
220-
bytes_in,
227+
raw_mvt_size,
221228
hit,
222229
} = joined??;
223230
stream_writer.add_tile(coord, &data)?;
224-
stats.record(data.len() as u64, bytes_in, hit);
231+
stats.record(data.len() as u64, raw_mvt_size, hit);
225232
bar.inc(1);
226233
}
227234

228235
stream_writer.finalize()?;
236+
let output_archive_size = std::fs::metadata(output)?.len();
229237
bar.finish_and_clear();
230-
stats.print_summary(start);
238+
stats.print_summary(
239+
start,
240+
input_archive_size,
241+
output_archive_size,
242+
encoding,
243+
tile_compression,
244+
);
231245

232246
Ok(())
233247
}

0 commit comments

Comments
 (0)