Merge pull request #624 from MarekSpodBiedry/issue-378

Space out browser downloads so image-sequence exports are not dropped (#378)
This commit is contained in:
Brandon Thomas 2026-10-10 14:53:13 -04:00 committed by GitHub
commit 9842fc0c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 67 additions and 4 deletions

View File

@ -0,0 +1,24 @@
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
//! anchor clicks live in [`crate::fs::download`]).
//!
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
//! (one file per frame) delivered only about half of its frames (#378). Each download is given a
//! slot [`GAP_MS`] after the previous one.
/// Milliseconds between two download clicks.
pub const GAP_MS: f64 = 125.0;
/// Hands out click times.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Pacer {
next_free: f64,
}
impl Pacer {
/// Reserve the next slot at time `now` (ms); returns how long to wait before clicking.
pub fn reserve(&mut self, now: f64) -> f64 {
let start = now.max(self.next_free);
self.next_free = start + GAP_MS;
start - now
}
}

View File

@ -54,6 +54,7 @@ struct Fs {
thread_local! {
static FS: RefCell<Fs> = RefCell::new(Fs::default());
static DOWNLOADS: RefCell<crate::download_pacing::Pacer> = RefCell::new(crate::download_pacing::Pacer::default());
}
/// Wake-up hook for arriving data.
@ -405,7 +406,8 @@ impl Services for WebServices {
}
}
/// Offer bytes as a browser download named `name`.
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let blob = bytes_to_blob(data, "application/octet-stream")?;
@ -414,10 +416,20 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
a.set_href(&url);
a.set_download(file_name(name));
a.style().set_property("display", "none")?;
doc.body().ok_or("no body")?.append_child(&a)?;
a.click();
a.remove();
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
let name = name.to_owned();
wasm_bindgen_futures::spawn_local(async move {
if delay > 0.0 {
sleep(delay as i32).await;
}
let clicked = match doc.body() {
Some(body) => body.append_child(&a).map(|_| a.click()),
None => Err("no body".into()),
};
a.remove();
if let Err(e) = clicked {
log::warn!("download {name}: {e:?}");
}
sleep(60_000).await;
let _ = web_sys::Url::revoke_object_url(&url);
});

View File

@ -20,6 +20,7 @@
pub mod api;
pub mod audio;
pub mod download_pacing;
pub mod fs;
pub mod import;
pub mod opfs;

View File

@ -0,0 +1,26 @@
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
//! wasm32-only.
#[path = "../src/download_pacing.rs"]
mod download_pacing;
use download_pacing::{GAP_MS, Pacer};
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
#[test]
fn burst_of_downloads_is_spaced_out() {
let mut p = Pacer::default();
let delays: Vec<f64> = (0..20).map(|_| p.reserve(1000.0)).collect();
assert_eq!(delays[0], 0.0);
for (i, d) in delays.iter().enumerate() {
assert_eq!(*d, i as f64 * GAP_MS);
}
}
/// A download after a quiet period is not delayed.
#[test]
fn download_after_a_pause_is_immediate() {
let mut p = Pacer::default();
assert_eq!(p.reserve(0.0), 0.0);
assert_eq!(p.reserve(10_000.0), 0.0);
}