mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb3815eb3 | ||
|
|
bf9fa3cc60 | ||
|
|
64669e9a7c | ||
|
|
44663d9601 | ||
|
|
a36228f0ec |
@@ -110,7 +110,7 @@ fn info(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
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) {
|
||||
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());
|
||||
@@ -134,7 +134,7 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
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) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
@@ -146,7 +146,7 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
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 {
|
||||
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
<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 />
|
||||
|
||||
@@ -182,7 +182,7 @@ fn services() -> Services {
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_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.save_file().map(|p| p.to_string_lossy().into_owned())
|
||||
|
||||
@@ -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).
|
||||
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).
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
};
|
||||
let n = clips.len();
|
||||
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 dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut audio = BTreeMap::new();
|
||||
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn decode_group_audio(
|
||||
audio_entries: &BTreeMap<u64, String>,
|
||||
dir: &Path,
|
||||
rate: u32,
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
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 &file.audio {
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(&dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
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);
|
||||
}
|
||||
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 s = e.session_mut();
|
||||
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
|
||||
.into_iter()
|
||||
.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)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -241,7 +241,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
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)?;
|
||||
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 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 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 = 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))
|
||||
|
||||
@@ -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> {
|
||||
let s = e.session();
|
||||
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 (index, playlist) in track.playlists.iter().enumerate() {
|
||||
let active = index == track.active_playlist;
|
||||
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
|
||||
for clip in &playlist.clips {
|
||||
let ClipContent::Audio { source, offset } = clip.content else { continue };
|
||||
clips += 1;
|
||||
if active {
|
||||
active_end = active_end.max(clip.end());
|
||||
}
|
||||
let metadata = sources.get(&source);
|
||||
let audio = s.pool.get(source);
|
||||
let mut add = |code: &str, message: &str| {
|
||||
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.");
|
||||
}
|
||||
let metadata = sources.get(&source).copied();
|
||||
let audio = s.pool.get(source).map(|a| a.as_ref());
|
||||
check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ impl CommandSpec {
|
||||
CommandInfo {
|
||||
id: self.id.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),
|
||||
params: self.params.into(),
|
||||
enabled: en.is_ok(),
|
||||
|
||||
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
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}")))?;
|
||||
if buf.frames() == 0 || buf.num_channels() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
|
||||
+16
-15
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
Some(p) => p.to_string(),
|
||||
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.
|
||||
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 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")))?;
|
||||
@@ -164,7 +165,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
|
||||
let track = match target {
|
||||
Some(t) => t,
|
||||
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))
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
/// 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> {
|
||||
let stem = Path::new(path).file_stem().and_then(|n| n.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 stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
|
||||
let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
|
||||
let bytes: Arc<[u8]> = bytes.into();
|
||||
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() });
|
||||
@@ -210,7 +211,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
|
||||
let vid = SourceId(s.alloc());
|
||||
s.videos.push(soundcraft_model::VideoSource {
|
||||
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,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
@@ -992,7 +993,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
sync_dir(&audio_dir);
|
||||
}
|
||||
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();
|
||||
}
|
||||
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
|
||||
/// newest ten.
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -1195,7 +1196,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
|
||||
.entry(file_key(p))
|
||||
.or_insert_with(|| {
|
||||
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()
|
||||
}
|
||||
@@ -1227,7 +1228,7 @@ pub fn set_deliver(h: DeliverHook) {
|
||||
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");
|
||||
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);
|
||||
}
|
||||
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`]).
|
||||
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();
|
||||
if let Some(stem) = stem {
|
||||
s.name = stem;
|
||||
@@ -1311,7 +1312,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
continue;
|
||||
}
|
||||
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),
|
||||
Err(err) => {
|
||||
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.
|
||||
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.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
|
||||
for m in &missing {
|
||||
@@ -1362,7 +1363,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
|
||||
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);
|
||||
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() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
@@ -1451,7 +1452,7 @@ fn add_recording_in(
|
||||
continue;
|
||||
}
|
||||
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 Some(x) = s.sources.last_mut() else { continue };
|
||||
x.unsaved = resampled;
|
||||
|
||||
@@ -449,7 +449,7 @@ fn install_panic_hook() {
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -196,7 +196,7 @@ impl ChannelFormat {
|
||||
pub fn channel_label(self, ch: usize) -> String {
|
||||
match self.speakers().get(ch) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ impl Default for EditState {
|
||||
timeline_selection: Range::default(),
|
||||
flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
values: std::collections::BTreeMap::new(),
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ pub fn parse_position(
|
||||
}
|
||||
TimeFormat::BarsBeats => {
|
||||
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 {
|
||||
bar: *parts.first().ok_or_else(err)?,
|
||||
beat: parts.get(1).copied().unwrap_or(1).max(1),
|
||||
|
||||
@@ -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.
|
||||
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();
|
||||
fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
for m in NameMode::ALL {
|
||||
@@ -242,6 +239,24 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
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);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
@@ -251,14 +266,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
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());
|
||||
}
|
||||
});
|
||||
contributors_grab_bag(ui, &list, v.names);
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
|
||||
@@ -776,7 +776,7 @@ impl SoundApp {
|
||||
for d in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() {
|
||||
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())
|
||||
&& best.as_ref().is_none_or(|(t, _)| m > *t)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
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(|| {
|
||||
[
|
||||
"Inserts A-E",
|
||||
@@ -633,7 +633,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
match sec {
|
||||
"all" => {
|
||||
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(),
|
||||
"" => return Some(Err("`section` required".into())),
|
||||
|
||||
@@ -371,6 +371,29 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
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) {
|
||||
ui.horizontal(|ui| {
|
||||
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.end_row();
|
||||
for (n, name, loc, len, kind) in rows {
|
||||
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.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();
|
||||
for (n, name, loc, len, kind) in &rows {
|
||||
memory_location_row(app, ui, *n, name, loc, len, kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -669,7 +673,7 @@ fn preset_menu(
|
||||
return;
|
||||
};
|
||||
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();
|
||||
names.sort();
|
||||
for n in &names {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use crate::SoundApp;
|
||||
use serde_json::json;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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.
|
||||
fn label(path: &str) -> String {
|
||||
let p = Path::new(path);
|
||||
let name = p.file_stem().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
match p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) {
|
||||
let name = p.file_stem().and_then(OsStr::to_str).unwrap_or(path);
|
||||
match p.parent().and_then(Path::file_name).and_then(OsStr::to_str) {
|
||||
Some(folder) => format!("{name} — {folder}"),
|
||||
None => name.to_string(),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::theme::{Tokens, mono, regular};
|
||||
use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Insert, TrackId};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
|
||||
+38
-30
@@ -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)
|
||||
}
|
||||
|
||||
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.
|
||||
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| {
|
||||
ui.label("Time Scale");
|
||||
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 { .. }) {
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
spot_field_row(ui, s, d, i, label);
|
||||
}
|
||||
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.end_row();
|
||||
if let Some(at) = d.stamp {
|
||||
ui.label("Original Time Stamp");
|
||||
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();
|
||||
original_time_stamp_row(ui, s, d, at);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
let t = Tokens::current();
|
||||
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 modes = [
|
||||
(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 {
|
||||
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 on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
|
||||
key(ui.painter(), 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 && 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);
|
||||
}
|
||||
draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label);
|
||||
let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
|
||||
if resp.clicked() {
|
||||
mode_click(app, m, ui.input(|i| i.modifiers));
|
||||
}
|
||||
resp.on_hover_text(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" }),
|
||||
});
|
||||
resp.on_hover_text(edit_mode_tooltip(m, state.locked, &cmd, fkey));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
app.video.poll(ctx);
|
||||
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| {
|
||||
ui.horizontal(|ui| {
|
||||
crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
|
||||
let text = 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(),
|
||||
};
|
||||
let text = video_status_text(app, pic.as_ref());
|
||||
ui.label(egui::RichText::new(text).font(regular(10.5)).color(Color32::from_gray(150)));
|
||||
});
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
@@ -60,28 +89,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
w = h * aspect;
|
||||
}
|
||||
let img = Rect::from_center_size(r.center(), vec2(w, h));
|
||||
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));
|
||||
}
|
||||
draw_video_surface(app, &painter, img, pic.as_ref(), on);
|
||||
if burn {
|
||||
let tc = s.format_position(pos, TimeFormat::Timecode);
|
||||
let size = (h / 12.0).clamp(10.0, 36.0);
|
||||
|
||||
@@ -341,7 +341,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
return None;
|
||||
}
|
||||
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()))
|
||||
.then_some((name, is_dir))
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ mod send_window;
|
||||
mod shared_controls;
|
||||
mod shortcuts;
|
||||
mod side_lists_access;
|
||||
mod toolbar_scroll;
|
||||
mod toolbar_tempo;
|
||||
mod track_height;
|
||||
mod track_list;
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -240,11 +240,11 @@ impl SliceDecoder<'_> {
|
||||
if c.decode_decision(base + inc) == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut prefix = 1;
|
||||
while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6)) == 1 {
|
||||
let mut prefix: i32 = 1;
|
||||
while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6) as usize) == 1 {
|
||||
prefix += 1;
|
||||
}
|
||||
let mut val = prefix as i32;
|
||||
let mut val = prefix;
|
||||
if prefix >= 9 {
|
||||
// EG3 suffix
|
||||
let mut k = 3;
|
||||
|
||||
@@ -575,7 +575,7 @@ impl Pps {
|
||||
ensure!(sps_id < 32, "seq_parameter_set_id out of range");
|
||||
let sps = spss
|
||||
.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}")))?;
|
||||
let entropy_coding_mode = r.read_flag()?;
|
||||
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
|
||||
|
||||
@@ -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));
|
||||
while let (Some(y), Some(cb), Some(cr)) = (yc.next(), cbc.next(), crc.next()) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
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).
|
||||
@@ -181,7 +181,7 @@ pub fn bundle_binary_for(os: Os, path: &Path) -> Option<PathBuf> {
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.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)
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
@@ -131,6 +131,10 @@ if has appimage; then
|
||||
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
|
||||
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)}"
|
||||
if [[ -z "$TOOL" ]]; then
|
||||
if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
|
||||
@@ -142,21 +146,21 @@ if has appimage; then
|
||||
download="$TOOL.download"
|
||||
curl --proto '=https' -fsSL -o "$download" \
|
||||
"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"
|
||||
mv "$download" "$TOOL"
|
||||
fi
|
||||
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c -
|
||||
verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL"
|
||||
fi
|
||||
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH"
|
||||
if [[ ! -f "$RUNTIME" ]]; then
|
||||
download="$RUNTIME.download"
|
||||
curl --proto '=https' -fsSL -o "$download" \
|
||||
"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"
|
||||
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
|
||||
# relative).
|
||||
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
|
||||
|
||||
+1
-1
@@ -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 mut missing = Vec::new();
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user