Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 dfb3815eb3 Move the #264 toolbar scroll test into the shared tests/it binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:50:07 -04:00
Brandon Thomas bf9fa3cc60 Merge remote-tracking branch 'origin/main' into m/946 2026-10-24 00:50:02 -04:00
Dylan M. Taylor 64669e9a7c chore: resolve SonarCloud maintainability issues (#1003)
* chore: resolve SonarCloud maintainability issues

* fix(packaging): extract verify_sha256 helper in package.sh

* style: apply rustfmt formatting

* fix(video): ensure prefix is i32 in cabac_mb
2026-10-23 22:33:59 -04:00
skylar taylor-barrick 44663d9601 #264: require the whole tempo field visible and unclipped
The test only checked the tempo text's left edge, so a field cut off on either side would still pass. It now checks that the whole text lies inside the window and inside the clip rect it is painted with. With the toolbar's scrolling turned off it still fails.
2026-10-22 19:13:26 -06:00
skylar taylor-barrick a36228f0ec #264: test that the Edit toolbar scrolls horizontally in a narrow window
The Edit toolbar laid its tool groups out in a bare row, so in a narrow window the groups on the right (the tempo among them) were clipped and unreachable. The test renders a 700 pt wide window, scrolls the toolbar sideways with the wheel and checks the tempo field is then painted inside the window. Without the horizontal ScrollArea the tempo is never painted, so the assertion fails.
2026-10-22 18:02:15 -06:00
32 changed files with 349 additions and 222 deletions
+3 -3
View File
@@ -110,7 +110,7 @@ fn info(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")), Err(e) => return fail(format!("{path}: {e}")),
}; };
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str()); let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
match soundcraft_audio_io::probe(&bytes, ext) { match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => { Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default()); outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
@@ -134,7 +134,7 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
}; };
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x, Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
@@ -146,7 +146,7 @@ fn convert(args: &[String]) -> ExitCode {
if args.iter().any(|a| a == "--normalize") { if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
} }
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else { let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)); return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
}; };
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoundCraft</title> <title>SoundCraft</title>
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" /> <link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs /> <link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
+1 -1
View File
@@ -182,7 +182,7 @@ fn services() -> Services {
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir); d = d.set_directory(dir);
} }
if let Some(n) = path.file_name().and_then(|n| n.to_str()) { if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n); d = d.set_file_name(n);
} }
d.save_file().map(|p| p.to_string_lossy().into_owned()) d.save_file().map(|p| p.to_string_lossy().into_owned())
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathB
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive). /// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
pub fn is_clap_path(p: &Path) -> bool { pub fn is_clap_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap")) p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
} }
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped). /// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
+30 -20
View File
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
}; };
let n = clips.len(); let n = clips.len();
lanes.sort_by_key(|(i, _)| *i); lanes.sort_by_key(|(i, _)| *i);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let mut audio = BTreeMap::new(); let mut audio = BTreeMap::new();
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
Ok(n) Ok(n)
} }
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks fn decode_group_audio(
/// are made when the session runs out). Returns the ids of the placed clips. audio_entries: &BTreeMap<u64, String>,
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> { dir: &Path,
let id = "file.import_clip_groups"; rate: u32,
let text = crate::io::read_text(path)?; cmd_id: &str,
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; ) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
let rate = e.session().sample_rate.hz(); for (old, rel) in audio_entries {
for (old, rel) in &file.audio {
let full = dir.join(rel); let full = dir.join(rel);
if !full.starts_with(&dir) || rel.contains("..") { if !full.starts_with(dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
} }
let key = crate::io::file_key(&full); let key = crate::io::file_key(&full);
let n = match index.get(&key) { let n = match index.get(&key) {
@@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
}; };
file_of.insert(*old, n); file_of.insert(*old, n);
} }
Ok((decoded, file_of))
}
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let rate = e.session().sample_rate.hz();
let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
let src_rate = f64::from(file.sample_rate.max(1)); let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut(); let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate; let ratio = f64::from(s.sample_rate.hz()) / src_rate;
@@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
let sources: Vec<SourceId> = decoded let sources: Vec<SourceId> = decoded
.into_iter() .into_iter()
.map(|(rel, buf)| { .map(|(rel, buf)| {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav) crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
}) })
.collect(); .collect();
+2 -2
View File
@@ -241,7 +241,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in &paths { for path in &paths {
let bytes = crate::io::read_file(path)?; let bytes = crate::io::read_file(path)?;
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
out.push(r); out.push(r);
} }
@@ -302,7 +302,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?; let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str()); let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| { let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)) bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
+53 -39
View File
@@ -12,6 +12,55 @@ pub fn specs() -> Vec<CommandSpec> {
] ]
} }
struct ClipContext<'a> {
track: &'a soundcraft_model::Track,
playlist: &'a soundcraft_model::Playlist,
active: bool,
session_sample_rate: u32,
}
fn check_audio_clip(
ctx: &ClipContext<'_>,
clip: &soundcraft_model::Clip,
source: soundcraft_model::SourceId,
offset: i64,
metadata: Option<&soundcraft_model::Source>,
audio: Option<&soundcraft_model::SourceAudio>,
issues: &mut Vec<Value>,
) {
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
"playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
return;
};
if audio.buffer.sample_rate != ctx.session_sample_rate {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
return;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
}
fn report(e: &mut Engine, _: &Value) -> Result<Value> { fn report(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session(); let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect(); let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
for track in &s.tracks { for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() { for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist; let active = index == track.active_playlist;
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
for clip in &playlist.clips { for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue }; let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1; clips += 1;
if active { if active {
active_end = active_end.max(clip.end()); active_end = active_end.max(clip.end());
} }
let metadata = sources.get(&source); let metadata = sources.get(&source).copied();
let audio = s.pool.get(source); let audio = s.pool.get(source).map(|a| a.as_ref());
let mut add = |code: &str, message: &str| { check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add(
"unavailable_audio",
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
);
continue;
};
if audio.buffer.sample_rate != s.sample_rate.hz() {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
continue;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add(
"uneven_channels",
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
);
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
} }
} }
} }
+1 -1
View File
@@ -81,7 +81,7 @@ impl CommandSpec {
CommandInfo { CommandInfo {
id: self.id.into(), id: self.id.into(),
label: self.label.into(), label: self.label.into(),
menu: self.menu.iter().map(|s| s.to_string()).collect(), menu: self.menu.iter().map(ToString::to_string).collect(),
shortcut: self.shortcut.map(str::to_string), shortcut: self.shortcut.map(str::to_string),
params: self.params.into(), params: self.params.into(),
enabled: en.is_ok(), enabled: en.is_ok(),
+2 -2
View File
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
} }
} }
let bytes = crate::io::read_file(&path)?; let bytes = crate::io::read_file(&path)?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
attach(e, t, &name, &bytes, Some(&path), values) attach(e, t, &name, &bytes, Some(&path), values)
} }
@@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(bad(id, "only instrument tracks have a sampler")); return Err(bad(id, "only instrument tracks have a sampler"));
} }
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 { if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio"))); return Err(EngineError::Io(format!("{name}: no audio")));
+16 -15
View File
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples}; use soundcraft_time::{Range, Samples};
use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -114,7 +115,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
buf.sample_rate = sr; buf.sample_rate = sr;
} }
let id = SourceId(s.alloc()); let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path { let stored = match path {
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem), None => unique_audio_rel(s, &stem),
@@ -145,7 +146,7 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
/// Decode and place an audio file. With no target track, a new track of matching width is made. /// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> { pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
@@ -164,7 +165,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
let track = match target { let track = match target {
Some(t) => t, Some(t) => t,
None => { None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
} }
}; };
@@ -188,8 +189,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails /// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails
/// only when the file has neither a readable picture nor audio. /// only when the file has neither a readable picture nor audio.
pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> { pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> {
let stem = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Video").to_string(); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
let ext = Path::new(path).extension().and_then(|x| x.to_str()).unwrap_or("mp4").to_ascii_lowercase(); let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
let bytes: Arc<[u8]> = bytes.into(); let bytes: Arc<[u8]> = bytes.into();
let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone()); let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone());
let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() }); let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() });
@@ -210,7 +211,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
let vid = SourceId(s.alloc()); let vid = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource { s.videos.push(soundcraft_model::VideoSource {
id: vid, id: vid,
name: Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("movie").to_string(), name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(),
path: abs, path: abs,
width: info.width, width: info.width,
height: info.height, height: info.height,
@@ -992,7 +993,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
sync_dir(&audio_dir); sync_dir(&audio_dir);
} }
s.folder = Some(dir.clone()); s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string(); s.name = stem.to_string();
} }
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
@@ -1052,7 +1053,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the /// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten. /// newest ten.
fn back_up(file: &Path) { fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str))
else { else {
return; return;
}; };
@@ -1195,7 +1196,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
.entry(file_key(p)) .entry(file_key(p))
.or_insert_with(|| { .or_insert_with(|| {
let bytes = read_file(p).map_err(|_| p.display().to_string())?; let bytes = read_file(p).map_err(|_| p.display().to_string())?;
decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display()))
}) })
.clone() .clone()
} }
@@ -1227,7 +1228,7 @@ pub fn set_deliver(h: DeliverHook) {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; 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"); let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io); return deliver(name, bytes).map_err(EngineError::Io);
} }
crate::file_access::check_write(path).map_err(EngineError::Io)?; crate::file_access::check_write(path).map_err(EngineError::Io)?;
@@ -1272,7 +1273,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). /// 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<usize> { fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone(); let mut s = e.session().clone();
if let Some(stem) = stem { if let Some(stem) = stem {
s.name = stem; s.name = stem;
@@ -1311,7 +1312,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
continue; continue;
} }
let audio = match files.remove(&src.path) { let audio = match files.remove(&src.path) {
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio), Ok(audio) => Some(audio),
Err(err) => { Err(err) => {
missing.push(format!("{}: {err}", src.path)); missing.push(format!("{}: {err}", src.path));
@@ -1330,7 +1331,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
} }
// Movies are referenced by path, never copied into a session: none can be found here. // 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())); 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(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s); e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing { for m in &missing {
@@ -1362,7 +1363,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
let mut data = Vec::new(); let mut data = Vec::new();
// The declared size is hostile too: never read past it. // 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}")))?; 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); let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() { if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else { } else {
@@ -1451,7 +1452,7 @@ fn add_recording_in(
continue; continue;
} }
let resampled = buf.sample_rate != s.sample_rate.hz(); let resampled = buf.sample_rate != s.sample_rate.hz();
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take"); let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav); let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue }; let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled; x.unsaved = resampled;
+1 -1
View File
@@ -449,7 +449,7 @@ fn install_panic_hook() {
} }
fn panic_message(p: &(dyn std::any::Any + Send)) -> String { fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into()) p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
} }
#[cfg(test)] #[cfg(test)]
+1 -1
View File
@@ -196,7 +196,7 @@ impl ChannelFormat {
pub fn channel_label(self, ch: usize) -> String { pub fn channel_label(self, ch: usize) -> String {
match self.speakers().get(ch) { match self.speakers().get(ch) {
Some(s) => s.label().to_string(), Some(s) => s.label().to_string(),
None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), |s| s.to_string()), None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string),
None => format!("{}", ch + 1), None => format!("{}", ch + 1),
} }
} }
+1 -1
View File
@@ -288,7 +288,7 @@ impl Default for EditState {
timeline_selection: Range::default(), timeline_selection: Range::default(),
flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"] flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"]
.iter() .iter()
.map(|s| s.to_string()) .map(ToString::to_string)
.collect(), .collect(),
values: std::collections::BTreeMap::new(), values: std::collections::BTreeMap::new(),
} }
+1 -1
View File
@@ -124,7 +124,7 @@ pub fn parse_position(
} }
TimeFormat::BarsBeats => { TimeFormat::BarsBeats => {
let parts: Vec<i64> = let parts: Vec<i64> =
t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(|p| p.parse::<i64>()).collect::<Result<_, _>>().map_err(|_| err())?; t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::<i64>).collect::<Result<_, _>>().map_err(|_| err())?;
let bb = BarBeat { let bb = BarBeat {
bar: *parts.first().ok_or_else(err)?, bar: *parts.first().ok_or_else(err)?,
beat: parts.get(1).copied().unwrap_or(1).max(1), beat: parts.get(1).copied().unwrap_or(1).max(1),
+20 -12
View File
@@ -210,10 +210,7 @@ impl Default for View {
} }
} }
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
ui.label("Show"); ui.label("Show");
for m in NameMode::ALL { for m in NameMode::ALL {
@@ -242,6 +239,24 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
v.table = true; v.table = true;
} }
}); });
}
fn contributors_grab_bag(ui: &mut egui::Ui, list: &[&Contributor], names: NameMode) {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
contributors_toolbar(ui, &mut v);
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator(); ui.separator();
@@ -251,14 +266,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
} else if v.table { } else if v.table {
table(ui, &list, &mut v); table(ui, &list, &mut v);
} else { } else {
ui.horizontal_wrapped(|ui| { contributors_grab_bag(ui, &list, v.names);
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
} }
}); });
ui.data_mut(|d| d.insert_temp(id, v)); ui.data_mut(|d| d.insert_temp(id, v));
+1 -1
View File
@@ -776,7 +776,7 @@ impl SoundApp {
for d in std::fs::read_dir(dir).ok()?.flatten() { for d in std::fs::read_dir(dir).ok()?.flatten() {
for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() { for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() {
let p = f.path(); let p = f.path();
if p.extension().and_then(|x| x.to_str()) == Some("scraft") if p.extension().and_then(std::ffi::OsStr::to_str) == Some("scraft")
&& let Ok(m) = f.metadata().and_then(|m| m.modified()) && let Ok(m) = f.metadata().and_then(|m| m.modified())
&& best.as_ref().is_none_or(|(t, _)| m > *t) && best.as_ref().is_none_or(|(t, _)| m > *t)
{ {
+2 -2
View File
@@ -86,7 +86,7 @@ pub fn ui_aliases() -> Vec<(&'static str, &'static str)> {
/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame. /// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame.
fn mix_view_aliases() -> &'static [(&'static str, &'static str)] { fn mix_view_aliases() -> &'static [(&'static str, &'static str)] {
static ALIASES: std::sync::OnceLock<Vec<(&'static str, &'static str)>> = std::sync::OnceLock::new(); static ALIASES: std::sync::OnceLock<Vec<(&str, &str)>> = std::sync::OnceLock::new();
ALIASES.get_or_init(|| { ALIASES.get_or_init(|| {
[ [
"Inserts A-E", "Inserts A-E",
@@ -633,7 +633,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
match sec { match sec {
"all" => { "all" => {
app.ui.mix_views = app.ui.mix_views =
["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(|s| s.to_string()).collect() ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(ToString::to_string).collect()
} }
"minimal" => app.ui.mix_views.clear(), "minimal" => app.ui.mix_views.clear(),
"" => return Some(Err("`section` required".into())), "" => return Some(Err("`section` required".into())),
+26 -22
View File
@@ -371,6 +371,29 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_big_counter = open; app.ui.show_big_counter = open;
} }
fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) {
if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
let _ = app.run("markers.recall", json!({"number": n}));
}
let key = egui::Id::new(("mem_name", n));
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.to_string());
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
if r.changed() {
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
}
if r.lost_focus() && edit != name {
let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
ui.ctx().memory_mut(|m| m.data.remove::<String>(key));
}
ui.label(egui::RichText::new(loc).font(mono(11.0)));
ui.label(egui::RichText::new(len).font(mono(11.0)));
ui.label(kind);
if ui.small_button("✕").on_hover_text("Delete").clicked() {
let _ = app.run("markers.delete", json!({"number": n}));
}
ui.end_row();
}
pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("+ Marker").clicked() { if ui.button("+ Marker").clicked() {
@@ -402,27 +425,8 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
ui.label(egui::RichText::new(h).strong()); ui.label(egui::RichText::new(h).strong());
} }
ui.end_row(); ui.end_row();
for (n, name, loc, len, kind) in rows { for (n, name, loc, len, kind) in &rows {
if ui.button(n.to_string()).on_hover_text("Recall").clicked() { memory_location_row(app, ui, *n, name, loc, len, kind);
let _ = app.run("markers.recall", json!({"number": n}));
}
let key = egui::Id::new(("mem_name", n));
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
if r.changed() {
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
}
if r.lost_focus() && edit != name {
let _ = app.run("markers.edit", json!({"number": n, "rename": edit}));
ui.ctx().memory_mut(|m| m.data.remove::<String>(key));
}
ui.label(egui::RichText::new(loc).font(mono(11.0)));
ui.label(egui::RichText::new(len).font(mono(11.0)));
ui.label(kind);
if ui.small_button("✕").on_hover_text("Delete").clicked() {
let _ = app.run("markers.delete", json!({"number": n}));
}
ui.end_row();
} }
}); });
}); });
@@ -669,7 +673,7 @@ fn preset_menu(
return; return;
}; };
let mut names: Vec<String> = std::fs::read_dir(&dir) let mut names: Vec<String> = std::fs::read_dir(&dir)
.map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(|x| x.to_str()).map(str::to_string)).collect()) .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_string)).collect())
.unwrap_or_default(); .unwrap_or_default();
names.sort(); names.sort();
for n in &names { for n in &names {
+3 -2
View File
@@ -13,6 +13,7 @@
use crate::SoundApp; use crate::SoundApp;
use serde_json::json; use serde_json::json;
use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
/// How many sessions the menu keeps. /// How many sessions the menu keeps.
@@ -45,8 +46,8 @@ pub fn note(list: &mut Vec<String>, path: &str) -> bool {
/// "name — folder" for a session path. /// "name — folder" for a session path.
fn label(path: &str) -> String { fn label(path: &str) -> String {
let p = Path::new(path); let p = Path::new(path);
let name = p.file_stem().and_then(|n| n.to_str()).unwrap_or(path); let name = p.file_stem().and_then(OsStr::to_str).unwrap_or(path);
match p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) { match p.parent().and_then(Path::file_name).and_then(OsStr::to_str) {
Some(folder) => format!("{name} — {folder}"), Some(folder) => format!("{name} — {folder}"),
None => name.to_string(), None => name.to_string(),
} }
+2 -1
View File
@@ -8,6 +8,7 @@ use crate::theme::{Tokens, mono, regular};
use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json; use serde_json::json;
use soundcraft_model::{Insert, TrackId}; use soundcraft_model::{Insert, TrackId};
use std::ffi::OsStr;
use std::path::Path; use std::path::Path;
/// The built-in Sampler's plugin id. /// The built-in Sampler's plugin id.
@@ -21,7 +22,7 @@ const MARKERS: [(&str, bool); 3] = [("start", false), ("loop_start", true), ("lo
/// Whether a file name looks like audio the sampler can load. /// Whether a file name looks like audio the sampler can load.
pub fn is_audio_name(name: &str) -> bool { pub fn is_audio_name(name: &str) -> bool {
Path::new(name).extension().and_then(|x| x.to_str()).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str())) Path::new(name).extension().and_then(OsStr::to_str).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str()))
} }
/// Sampler parameters that are MIDI notes. /// Sampler parameters that are MIDI notes.
+38 -30
View File
@@ -113,9 +113,44 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples {
i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start)
} }
fn spot_field_row(ui: &mut Ui, s: &Session, d: &mut Spot, i: usize, label: &str) {
let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
ui.label(label);
if d.editable(i) {
let id = Id::new(("spot_field", i));
if !ui.memory(|m| m.has_focus(id)) {
d.text[i] = show(d.value(i), d.format);
}
let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
if r.changed()
&& let Ok(v) = s.parse_position(&d.text[i], d.format)
&& (-MAX_POSITION..=MAX_POSITION).contains(&v)
{
d.set(i, v);
}
if std::mem::take(&mut d.fresh) {
r.request_focus();
select_all(ui, id, &d.text[i]);
}
} else {
ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0)));
}
ui.end_row();
}
fn original_time_stamp_row(ui: &mut Ui, s: &Session, d: &mut Spot, at: Samples) {
ui.label("Original Time Stamp");
ui.horizontal(|ui| {
ui.label(RichText::new(s.format_position(at, TimeFormat::Timecode)).font(mono(13.0)));
if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
d.start = at;
}
});
ui.end_row();
}
/// The dialog's fields; the caller adds OK and Cancel. /// The dialog's fields; the caller adds OK and Cancel.
pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Time Scale"); ui.label("Time Scale");
egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
@@ -128,40 +163,13 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
if i == 1 && matches!(d.target, Target::Trim { .. }) { if i == 1 && matches!(d.target, Target::Trim { .. }) {
continue; continue;
} }
ui.label(label); spot_field_row(ui, s, d, i, label);
if d.editable(i) {
let id = Id::new(("spot_field", i));
if !ui.memory(|m| m.has_focus(id)) {
d.text[i] = show(d.value(i), d.format);
}
let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
if r.changed()
&& let Ok(v) = s.parse_position(&d.text[i], d.format)
&& (-MAX_POSITION..=MAX_POSITION).contains(&v)
{
d.set(i, v);
}
if std::mem::take(&mut d.fresh) {
r.request_focus();
select_all(ui, id, &d.text[i]);
}
} else {
ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0)));
}
ui.end_row();
} }
ui.label("Duration"); ui.label("Duration");
ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0))); ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0)));
ui.end_row(); ui.end_row();
if let Some(at) = d.stamp { if let Some(at) = d.stamp {
ui.label("Original Time Stamp"); original_time_stamp_row(ui, s, d, at);
ui.horizontal(|ui| {
ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0)));
if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
d.start = at;
}
});
ui.end_row();
} }
}); });
} }
+34 -19
View File
@@ -43,10 +43,40 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
}); });
} }
struct ModeCellState {
cur: EditMode,
snap: bool,
locked: bool,
}
fn edit_mode_tooltip(m: EditMode, locked: bool, cmd: &str, fkey: &str) -> String {
match m {
EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
_ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
}
}
fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Tokens, m: EditMode, state: &ModeCellState, label: &str) {
let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
key(p, cell, resp, if on { t.mode_on } else { t.mode_off });
let fg = if on { t.mode_on_text } else { t.mode_off_text };
if m == EditMode::Grid && state.snap {
p.rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside);
}
if m == EditMode::Shuffle && state.locked {
icons::draw(p, Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg);
} else {
let txt = if m == EditMode::Grid && state.cur == EditMode::GridRelative { "REL GRID" } else { label };
p.text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg);
}
}
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current(); let t = Tokens::current();
let ed = &app.engine.session().edit; let ed = &app.engine.session().edit;
let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)); let state = ModeCellState { cur: ed.edit_mode, snap: ed.flag(SNAP_TO_GRID), locked: ed.flag(SHUFFLE_LOCK) };
let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac); let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac);
let modes = [ let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
@@ -63,28 +93,13 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
for (m, cx, cy, label, fkey) in modes { for (m, cx, cy, label, fkey) in modes {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label);
key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off }); let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
let fg = if on { t.mode_on_text } else { t.mode_off_text };
if m == EditMode::Grid && snap {
ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside);
}
if m == EditMode::Shuffle && locked {
icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg);
} else {
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
if resp.clicked() { if resp.clicked() {
mode_click(app, m, ui.input(|i| i.modifiers)); mode_click(app, m, ui.input(|i| i.modifiers));
} }
resp.on_hover_text(match m { resp.on_hover_text(edit_mode_tooltip(m, state.locked, &cmd, fkey));
EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
_ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
});
} }
}); });
} }
+39 -31
View File
@@ -18,6 +18,43 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
} }
} }
fn video_status_text(app: &SoundApp, pic: Option<&(MovieKey, Result<String, String>, f64, f64)>) -> String {
match pic {
Some((_, Err(why), ..)) => why.clone(),
Some((k, ..)) => match app.video.info(*k) {
Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
Some(Err(e)) => e.clone(),
None => "opening…".into(),
},
None => String::new(),
}
}
fn draw_video_surface(app: &SoundApp, painter: &egui::Painter, img: Rect, pic: Option<&(MovieKey, Result<String, String>, f64, f64)>, on: bool) {
let msg = if !on {
Some("Video Track Offline".to_string())
} else {
match pic {
None => None,
Some((_, Err(why), ..)) => Some(why.clone()),
Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
(Some(tex), _) => {
painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
None
}
(None, Some(e)) => Some(e.to_string()),
(None, None) => match app.video.info(*k) {
Some(Err(e)) => Some(e.clone()),
_ => None,
},
},
}
};
if let Some(m) = msg {
painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
}
}
fn video(app: &mut SoundApp, ctx: &egui::Context) { fn video(app: &mut SoundApp, ctx: &egui::Context) {
app.video.poll(ctx); app.video.poll(ctx);
let pos = app.position(); let pos = app.position();
@@ -38,15 +75,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| { egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in"); crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
let text = match &pic { let text = video_status_text(app, pic.as_ref());
Some((_, Err(why), ..)) => why.clone(),
Some((k, ..)) => match app.video.info(*k) {
Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
Some(Err(e)) => e.clone(),
None => "opening…".into(),
},
None => String::new(),
};
ui.label(egui::RichText::new(text).font(regular(10.5)).color(Color32::from_gray(150))); ui.label(egui::RichText::new(text).font(regular(10.5)).color(Color32::from_gray(150)));
}); });
let avail = ui.available_rect_before_wrap(); let avail = ui.available_rect_before_wrap();
@@ -60,28 +89,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
w = h * aspect; w = h * aspect;
} }
let img = Rect::from_center_size(r.center(), vec2(w, h)); let img = Rect::from_center_size(r.center(), vec2(w, h));
let msg = if !on { draw_video_surface(app, &painter, img, pic.as_ref(), on);
Some("Video Track Offline".to_string())
} else {
match &pic {
None => None,
Some((_, Err(why), ..)) => Some(why.clone()),
Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
(Some(tex), _) => {
painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
None
}
(None, Some(e)) => Some(e.to_string()),
(None, None) => match app.video.info(*k) {
Some(Err(e)) => Some(e.clone()),
_ => None,
},
},
}
};
if let Some(m) = msg {
painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
}
if burn { if burn {
let tc = s.format_position(pos, TimeFormat::Timecode); let tc = s.format_position(pos, TimeFormat::Timecode);
let size = (h / 12.0).clamp(10.0, 36.0); let size = (h / 12.0).clamp(10.0, 36.0);
+1 -1
View File
@@ -341,7 +341,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
return None; return None;
} }
let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); let ext = std::path::Path::new(&name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
.then_some((name, is_dir)) .then_some((name, is_dir))
}) })
+1
View File
@@ -42,6 +42,7 @@ mod send_window;
mod shared_controls; mod shared_controls;
mod shortcuts; mod shortcuts;
mod side_lists_access; mod side_lists_access;
mod toolbar_scroll;
mod toolbar_tempo; mod toolbar_tempo;
mod track_height; mod track_height;
mod track_list; mod track_list;
+52
View File
@@ -0,0 +1,52 @@
//! Regression test for #264: the Edit toolbar clipped its tools in a narrow window; it now scrolls horizontally.
use egui::{Event, Pos2, Shape, vec2};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp};
const WIDTH: f32 = 700.0;
/// The toolbar's tempo text (the last group but one) and the clip rect it is painted in, once painted.
fn tempo(h: &egui_kittest::Harness<'_, SoundApp>) -> Option<(egui::Rect, egui::Rect)> {
h.output().shapes.iter().find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == "120.0000" => Some((t.galley.rect.translate(t.pos.to_vec2()), c.clip_rect)),
_ => None,
})
}
#[test]
fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() {
let mut engine = soundcraft_engine::Engine::default();
engine.execute("track.new", &json!({})).unwrap();
let app = SoundApp::new(engine, None, Services::default());
let mut h = egui_kittest::Harness::builder().with_size(vec2(WIDTH, 700.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
// Off the right edge, so egui culls it (or paints it beyond the window).
assert!(tempo(&h).is_none_or(|(r, _)| r.min.x > WIDTH), "the tempo should start off-screen in a {WIDTH} pt window");
let y = 40.0;
// Wheel over the toolbar (a vertical wheel does not scroll a horizontal-only area, a horizontal one does).
for _ in 0..12 {
h.event(Event::PointerMoved(Pos2::new(WIDTH / 2.0, y)));
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-300.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.step();
}
let at = tempo(&h);
assert!(
at.is_some_and(|(r, clip)| r.min.x >= 0.0 && r.max.x <= WIDTH && clip.contains_rect(r)),
"scrolling the toolbar must bring the whole tempo into the window, unclipped: (text, clip) = {at:?}"
);
}
+3 -3
View File
@@ -240,11 +240,11 @@ impl SliceDecoder<'_> {
if c.decode_decision(base + inc) == 0 { if c.decode_decision(base + inc) == 0 {
return 0; return 0;
} }
let mut prefix = 1; let mut prefix: i32 = 1;
while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6)) == 1 { while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6) as usize) == 1 {
prefix += 1; prefix += 1;
} }
let mut val = prefix as i32; let mut val = prefix;
if prefix >= 9 { if prefix >= 9 {
// EG3 suffix // EG3 suffix
let mut k = 3; let mut k = 3;
+1 -1
View File
@@ -575,7 +575,7 @@ impl Pps {
ensure!(sps_id < 32, "seq_parameter_set_id out of range"); ensure!(sps_id < 32, "seq_parameter_set_id out of range");
let sps = spss let sps = spss
.get(sps_id as usize) .get(sps_id as usize)
.and_then(|s| s.as_ref()) .and_then(Option::as_ref)
.ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?; .ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?; let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?; let bottom_field_pic_order_in_frame_present = r.read_flag()?;
+1 -1
View File
@@ -200,7 +200,7 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) ->
let mut ac = alpha.as_mut().map(|a| a.chunks_mut(band_h * width)); let mut ac = alpha.as_mut().map(|a| a.chunks_mut(band_h * width));
while let (Some(y), Some(cb), Some(cr)) = (yc.next(), cbc.next(), crc.next()) { while let (Some(y), Some(cb), Some(cr)) = (yc.next(), cbc.next(), crc.next()) {
let rows = y.len() / width; let rows = y.len() / width;
let a = ac.as_mut().and_then(|it| it.next()); let a = ac.as_mut().and_then(Iterator::next);
bands.push(Band { y, cb, cr, a, rows }); bands.push(Band { y, cb, cr, a, rows });
} }
} }
+2 -2
View File
@@ -79,7 +79,7 @@ pub fn os_search_paths(os: Os, home: Option<PathBuf>, common_program_files: Opti
/// True when the path names a VST3 plugin (`*.vst3`, case-insensitive). /// True when the path names a VST3 plugin (`*.vst3`, case-insensitive).
pub fn is_vst3_path(p: &Path) -> bool { pub fn is_vst3_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("vst3")) p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("vst3"))
} }
/// Recursively collects `.vst3` bundles and files under `dirs` (missing directories are skipped). /// Recursively collects `.vst3` bundles and files under `dirs` (missing directories are skipped).
@@ -181,7 +181,7 @@ pub fn bundle_binary_for(os: Os, path: &Path) -> Option<PathBuf> {
.flatten() .flatten()
.map(|e| e.path()) .map(|e| e.path())
.filter(|p| p.is_file()) .filter(|p| p.is_file())
.filter(|p| ext.is_none_or(|e| p.extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e)))) .filter(|p| ext.is_none_or(|e| p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| x.eq_ignore_ascii_case(e))))
.take(64) .take(64)
.collect(); .collect();
files.sort(); files.sort();
+8 -4
View File
@@ -131,6 +131,10 @@ if has appimage; then
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
ln -s "$APP_ID.png" "$APPDIR/.DirIcon" ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
verify_sha256() {
local sha="$1" file="$2"
printf '%s %s\n' "$sha" "$file" | sha256sum -c -
}
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [[ -z "$TOOL" ]]; then if [[ -z "$TOOL" ]]; then
if [[ -z "$APPIMAGETOOL_SHA256" ]]; then if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
@@ -142,21 +146,21 @@ if has appimage; then
download="$TOOL.download" download="$TOOL.download"
curl --proto '=https' -fsSL -o "$download" \ curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - verify_sha256 "$APPIMAGETOOL_SHA256" "$download"
chmod 755 "$download" chmod 755 "$download"
mv "$download" "$TOOL" mv "$download" "$TOOL"
fi fi
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL"
fi fi
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH"
if [[ ! -f "$RUNTIME" ]]; then if [[ ! -f "$RUNTIME" ]]; then
download="$RUNTIME.download" download="$RUNTIME.download"
curl --proto '=https' -fsSL -o "$download" \ curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH"
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download"
mv "$download" "$RUNTIME" mv "$download" "$RUNTIME"
fi fi
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative). # relative).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
+1 -1
View File
@@ -97,7 +97,7 @@ fn assets() -> Result<(), String> {
let attribution = std::fs::read_to_string(root().join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; let attribution = std::fs::read_to_string(root().join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
let mut missing = Vec::new(); let mut missing = Vec::new();
for f in files.lines() { for f in files.lines() {
let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); let ext = Path::new(f).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if !ASSET_EXT.contains(&ext.as_str()) { if !ASSET_EXT.contains(&ext.as_str()) {
continue; continue;
} }