diff --git a/Cargo.lock b/Cargo.lock index 8b26758..7428447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2133,6 +2133,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3502,6 +3514,7 @@ dependencies = [ "soundcraft-video", "soundcraft-vst3-host", "thiserror 2.0.21", + "zip", ] [[package]] @@ -3623,8 +3636,12 @@ version = "0.4.0" dependencies = [ "eframe", "egui", + "js-sys", "log", + "rfd", + "serde_json", "soundcraft-engine", + "soundcraft-model", "soundcraft-playback", "soundcraft-ui-egui", "wasm-bindgen", @@ -4084,6 +4101,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5374,6 +5397,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +dependencies = [ + "crc32fast", + "indexmap", + "memchr", + "nix", + "typed-path", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 95b9a8e..bb61412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,8 @@ proptest = "1" # audio symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } rustfft = "6" +# Session bundles (one file holding a session and its audio, for the browser): stored entries only. +zip = { version = "9", default-features = false } cpal = "0.16" # ui diff --git a/apps/soundcraft-web/Cargo.toml b/apps/soundcraft-web/Cargo.toml index 64cdb1a..50ff3aa 100644 --- a/apps/soundcraft-web/Cargo.toml +++ b/apps/soundcraft-web/Cargo.toml @@ -17,10 +17,14 @@ path = "src/main.rs" soundcraft-engine = { workspace = true } soundcraft-ui-egui = { workspace = true } soundcraft-playback = { workspace = true } +soundcraft-model = { workspace = true } egui = { workspace = true } log = { workspace = true } +serde_json = { workspace = true } +rfd = { workspace = true } +js-sys = "0.3" # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] } diff --git a/apps/soundcraft-web/src/main.rs b/apps/soundcraft-web/src/main.rs index cb089a5..6e52278 100644 --- a/apps/soundcraft-web/src/main.rs +++ b/apps/soundcraft-web/src/main.rs @@ -7,7 +7,10 @@ //! //! Differences from the desktop app: //! - no TCP control channel (browsers can't listen on sockets); -//! - no native file dialogs: the demo session is loaded on start; +//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are +//! read as bytes through the browser's file picker; a session is saved as ONE bundle file +//! (`.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a +//! download (the demo session is still loaded on start); //! - browsers only start audio after a user gesture, so the first click may be needed before //! playback is heard. //! diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 78acba4..02f1e13 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -1,7 +1,11 @@ //! The browser shell: the eframe web runner around [`SoundApp`]. -use soundcraft_engine::Engine; +use serde_json::json; +use soundcraft_engine::{Engine, io}; +use soundcraft_ui_egui::dialogs::Dialog; use soundcraft_ui_egui::{Services, SoundApp}; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; use wasm_bindgen::JsCast as _; @@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading"; pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + io::set_deliver(download); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -36,7 +41,7 @@ pub fn start() { let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() }; let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone())); let app = SoundApp::new(engine, Some(player), Services::default()); - Ok(Box::new(WebShell(app))) + Ok(Box::new(WebShell { app, inbox: Rc::default() })) }), ) .await; @@ -53,18 +58,182 @@ fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } -struct WebShell(SoundApp); +/// What a file read from the browser is for. +#[derive(Debug, Clone, Copy)] +enum Purpose { + Session, + Audio, + Midi, +} + +const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; +const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"]; +const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; + +impl Purpose { + /// A dropped file goes where its extension says: sessions open, MIDI and audio import. + fn of(name: &str) -> Self { + let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default(); + if SESSION_EXTS.contains(&ext.as_str()) { + Self::Session + } else if MIDI_EXTS.contains(&ext.as_str()) { + Self::Midi + } else { + Self::Audio + } + } + + /// The command whose path prompt this replaces in the browser. + fn for_command(cmd: &str) -> Option { + match cmd { + "session.open" => Some(Self::Session), + "file.import_audio" => Some(Self::Audio), + "file.import_midi" => Some(Self::Midi), + _ => None, + } + } + + fn filter(self) -> (&'static str, &'static [&'static str]) { + match self { + Self::Session => ("SoundCraft Session", SESSION_EXTS), + Self::Audio => ("Audio", AUDIO_EXTS), + Self::Midi => ("MIDI", MIDI_EXTS), + } + } +} + +/// Files picked or dropped, read asynchronously (browsers can only read files that way) and +/// handled on the next frame. The browser has no paths: files travel as bytes. +type Inbox = Rc)>>>; + +struct WebShell { + app: SoundApp, + inbox: Inbox, +} + +impl WebShell { + /// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the + /// browser can't read (#63, #64). Show the browser's file picker instead. + fn take_path_prompt(&mut self, ctx: &egui::Context) { + let purpose = match &self.app.dialogs.open { + Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd), + _ => None, + }; + let Some(purpose) = purpose else { return }; + self.app.dialogs.open = None; + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + let (label, exts) = purpose.filter(); + let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return }; + let bytes = file.read().await; + inbox.borrow_mut().push((purpose, file.file_name(), bytes)); + ctx.request_repaint(); + }); + } + + fn take_drops(&mut self, ctx: &egui::Context) { + // In the browser a dropped file's `path()` is only its name: read its bytes instead. + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into()); + let (inbox, ctx) = (self.inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + inbox.borrow_mut().push((Purpose::of(&name), name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } + + fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + let e = &mut self.app.engine; + let at = e.session().edit.selection.start.max(0); + let result = match purpose { + Purpose::Session => { + let _ = self.app.run("transport.stop", json!({})); + io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| { + if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) } + }) + } + // Imports are edits: one undo step each, like the import commands. + Purpose::Audio => { + let before = e.session_arc(); + io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| { + e.push_undo("Import Audio", before); + format!("Imported {name}") + }) + } + Purpose::Midi => { + let before = e.session_arc(); + io::import_midi_bytes(e, bytes, at, true).map(|_| { + e.push_undo("Import MIDI", before); + format!("Imported {name}") + }) + } + }; + self.app.ui.status = result.unwrap_or_else(|err| err.to_string()); + } +} impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.0.logic(ctx); + self.take_drops(ctx); + self.take_path_prompt(ctx); + let pending = std::mem::take(&mut *self.inbox.borrow_mut()); + for (purpose, name, bytes) in pending { + self.handle(purpose, &name, &bytes); + } + self.app.logic(ctx); + // A prompt opened during this frame becomes a picker on the next one. + self.take_path_prompt(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.app.ui(ui); } } + +/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery +/// hook on the web). +fn download(name: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}"); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() { + Some("scraftz") => "application/zip", + Some("wav") => "audio/wav", + Some("aif" | "aiff") => "audio/aiff", + Some("flac") => "audio/flac", + Some("mid" | "midi") => "audio/midi", + Some("svg") => "image/svg+xml", + Some("txt") => "text/plain", + Some("json" | "scraft") => "application/json", + Some("musicxml") => "application/vnd.recordare.musicxml+xml", + _ => "application/octet-stream", + }); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + a.set_href(&url); + a.set_download(name); + a.style().set_property("display", "none").map_err(js)?; + document.body().ok_or("no body")?.append_child(&a).map_err(js)?; + a.click(); + a.remove(); + // Revoke after the click was dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; + Ok(()) +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b698856..9b097a0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true log.workspace = true +zip.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..4e26b85 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { s.sources.clear(); s.videos.clear(); let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; Ok(json!({"path": path})) }), cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| { @@ -99,13 +99,13 @@ pub fn specs() -> Vec { cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); let bytes = crate::io::export_midi(e)?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| { let text = crate::inspect::session_text(e); if let Some(path) = str_param(p, "path") { - std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(path, text.as_bytes())?; } Ok(json!({"text": text})) }), @@ -261,7 +261,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; - std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, &bytes)?; Ok( json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), ) @@ -280,7 +280,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result Some(p) => p.to_string(), None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), }; - std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); Ok(json!({"path": path, "parts": parts.len(), "measures": measures})) } diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index e3d202e..8818342 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result { "comments": tr.comments, }); let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + crate::io::write_file(&path, text.as_bytes())?; Ok(json!({"path": path, "track": first})) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..8c774d8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -456,7 +456,13 @@ pub fn sanitize_name(n: &str) -> String { /// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written /// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written. +/// +/// In the browser there are no folders: the session and all its audio go out as one bundle file +/// (`.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]). pub fn save_session(e: &mut Engine, path: &str) -> Result { + if cfg!(target_arch = "wasm32") { + return save_session_bundle(e, path); + } let (fresh, written, p) = write_session(e.session(), path, false)?; // Keep the in-memory document in sync (paths, unsaved flags) without an undo step. *e.session_mut() = fresh; @@ -535,16 +541,11 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { for src in s.sources.clone() { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; match std::fs::read(&p) { - Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) { - Ok((_, mut buf)) => { - if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { - buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); - buf.sample_rate = s.sample_rate.hz(); - } - s.pool.insert(src.id, Arc::new(SourceAudio::new(buf))); + Ok(bytes) => { + if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) { + missing.push(format!("{}: {err}", p.display())); } - Err(err) => missing.push(format!("{}: {err}", p.display())), - }, + } Err(_) => missing.push(p.display().to_string()), } } @@ -557,6 +558,173 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } +/// Decode one source's audio file into the session pool, at the session rate. +fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> { + let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; + if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); + buf.sample_rate = s.sample_rate.hz(); + } + s.pool.insert(id, Arc::new(SourceAudio::new(buf))); + Ok(()) +} + +// ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── + +/// Extension of a session bundle: one zip file holding the `.scraft` session and its +/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it +/// saves and opens sessions as bundles. +pub const BUNDLE_EXTENSION: &str = "scraftz"; + +/// Most entries and bytes a bundle may unpack to (bundles are hostile input). +const MAX_BUNDLE_ENTRIES: usize = 10_000; +const MAX_BUNDLE_BYTES: u64 = 4 << 30; + +/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app +/// installs one that starts a download. Native builds write files and never install it. +pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>; + +static DELIVER: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Install the delivery hook (once, at start-up of the web app). +pub fn set_deliver(h: DeliverHook) { + let _ = DELIVER.set(h); +} + +/// Write one output file: to `path` natively; in the browser, delivered under its file name. +/// Without this, every export on the web failed with «operation not supported on this platform». +pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + if cfg!(target_arch = "wasm32") { + let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; + let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); + return deliver(name, bytes).map_err(EngineError::Io); + } + std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}"))) +} + +/// The session as a bundle: `.scraft` plus every source's audio as 32-bit float WAV under +/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the +/// session with its source paths pointing into the bundle. +pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { + use std::io::Write as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}")); + let mut s = session.clone(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true); + let mut taken = std::collections::HashSet::new(); + for src in &mut s.sources { + let Some(audio) = s.pool.get(src.id) else { continue }; + let base = sanitize_name(&src.name); + let mut target = format!("Audio Files/{base}.wav"); + let mut k = 1; + while taken.contains(&target) && k <= 9_999 { + target = format!("Audio Files/{base}_{k:02}.wav"); + k += 1; + } + let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(target.as_str(), opts).map_err(zerr)?; + w.write_all(&bytes).map_err(ioerr)?; + taken.insert(target.clone()); + src.path = target; + src.unsaved = false; + src.format = FileFormat::Wav; + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?; + w.write_all(text.as_bytes()).map_err(ioerr)?; + let bytes = w.finish().map_err(zerr)?.into_inner(); + Ok((bytes, s)) +} + +/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). +fn save_session_bundle(e: &mut Engine, path: &str) -> Result { + let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); + let mut s = e.session().clone(); + if let Some(stem) = stem { + s.name = stem; + } + let (bytes, fresh) = session_bundle(&s)?; + let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name)); + write_file(&file, &bytes)?; + let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count(); + *e.session_mut() = fresh; + e.path = Some(file); + e.mark_clean(); + Ok(written) +} + +/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare +/// `.scraft`, whose audio is then reported missing. Returns the missing media, like +/// [`open_session`]. +pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result> { + let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") { + read_bundle(bytes)? + } else { + ( + String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?, + std::collections::HashMap::new(), + ) + }; + let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; + let mut missing = Vec::new(); + for src in s.sources.clone() { + match files.remove(&src.path) { + Some(audio) => { + let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string); + if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) { + missing.push(format!("{}: {err}", src.path)); + } + } + None => missing.push(src.path.clone()), + } + } + // Movies are referenced by path, never copied into a session: none can be found here. + missing.extend(s.videos.iter().map(|v| v.path.clone())); + let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string(); + e.replace_session(s); + e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); + for m in &missing { + e.message(format!("missing media: {m}")); + } + Ok(missing) +} + +/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path. +fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap>)> { + use std::io::Read as _; + let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}")); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?; + if z.len() > MAX_BUNDLE_ENTRIES { + return Err(EngineError::Io("bundle: too many files".into())); + } + let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64); + for i in 0..z.len() { + let mut f = z.by_index(i).map_err(zerr)?; + if f.is_dir() { + continue; + } + let rel = f.name().map_err(zerr)?.to_string(); + let size = f.size(); + total = total.saturating_add(size); + if total > MAX_BUNDLE_BYTES { + return Err(EngineError::Io("bundle: too large".into())); + } + let mut data = Vec::new(); + // The declared size is hostile too: never read past it. + f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?; + let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION); + if is_session && text.is_none() { + text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); + } else { + files.insert(rel, data); + } + } + let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?; + Ok((text, files)) +} + /// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record"). /// `channels` is the planar input capture at `rate`; each track takes the input channels its /// input route names ("In 3" → channel 2), defaulting to the first channels. @@ -638,3 +806,75 @@ mod stem_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod bundle_tests { + use super::*; + + #[test] + fn bundle_round_trips_session_and_audio() { + let mut e = crate::demo::demo_engine(); + e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap(); + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved)); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap(); + assert!(missing.is_empty(), "{missing:?}"); + assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); + assert_eq!(r.session().tracks.len(), e.session().tracks.len()); + for src in &e.session().sources { + let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap()); + assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name); + } + assert!(!r.is_dirty()); + assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz")); + } + + #[test] + fn same_named_sources_get_distinct_files() { + let mut e = crate::demo::demo_engine(); + let name = e.session().sources[0].name.clone(); + let s = e.session_mut(); + let n = s.sources.len(); + s.sources[1].name = name; + let (bytes, fresh) = session_bundle(e.session()).unwrap(); + let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect(); + assert_eq!(paths.len(), n); + let mut r = Engine::default(); + assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty()); + } + + #[test] + fn bare_session_opens_with_its_audio_reported_missing() { + let e = crate::demo::demo_engine(); + let (_, fresh) = session_bundle(e.session()).unwrap(); + let text = fresh.to_json().unwrap(); + let mut r = Engine::default(); + let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap(); + assert_eq!(missing.len(), fresh.sources.len()); + assert_eq!(r.session().tracks.len(), fresh.tracks.len()); + } + + #[test] + fn hostile_bundles_are_errors_not_panics() { + let mut r = Engine::default(); + for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] { + assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err()); + } + // A valid zip without a session inside. + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap(); + let only_audio = w.finish().unwrap().into_inner(); + assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err()); + assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone"); + } + + #[test] + fn write_file_writes_to_disk_natively() { + let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id())); + write_file(&p.to_string_lossy(), b"hello").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"hello"); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d212634..add16b9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -75,7 +75,12 @@ impl Dialogs { "session.save_as" | "session.save_copy" => Dialog::PathPrompt { cmd: id.into(), title: "Save Session As".into(), - path: format!("{home}/{name}/{name}.scraft"), + // The browser has no folders: the name becomes the bundle file's name. + path: if home.is_empty() { + format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION) + } else { + format!("{home}/{name}/{name}.scraft") + }, key: "path".into(), }, "edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },