diff --git a/Cargo.lock b/Cargo.lock index 49a22e8..3ae97d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -906,6 +906,7 @@ dependencies = [ "claudius-decode", "claudius-packs", "dirs", + "image", "rayon", "rmcp", "schemars", @@ -927,6 +928,7 @@ dependencies = [ "ehttp", "env_logger", "hound", + "image", "js-sys", "log", "rodio", diff --git a/crates/claudius-api/src/lib.rs b/crates/claudius-api/src/lib.rs index 02f02ef..41ba25f 100644 --- a/crates/claudius-api/src/lib.rs +++ b/crates/claudius-api/src/lib.rs @@ -12,6 +12,9 @@ //! | `GET /api/audio/{version}.wav` / `.mp3` | the render of that state version (`RenderView.url` points here) | //! | `GET /api/stems/{version}/{track}.wav` | one track of that render, when it was rendered with stems | //! | `GET /api/export/{version}/{name}.{ext}?format=SPEC` | that render in another format (`ExportFormat.spec`), as a download named `{name}.{ext}` | +//! | `POST /api/release/artwork` | body: a JPEG or PNG (`Content-Type: image/jpeg`, `image/png` or `application/octet-stream`), the release's artwork; answer: `Reply` | +//! | `GET /api/release/artwork` | the release's artwork, as it was given | +//! | `GET /api/release/preview.png` | the artwork at most 512 pixels on a side, as PNG | //! | `POST /mcp` | the MCP endpoint (Streamable HTTP) | //! | `GET /` | the web UI | @@ -83,6 +86,70 @@ pub struct State { /// the formats a render can be exported to (`/api/export`, `Command::Export`) #[serde(default)] pub exports: Vec, + /// the release the session is preparing for the open song (`Command::SetRelease`, `Command::WriteKit`) + #[serde(default)] + pub release: Option, +} + +/// What stores and distributors ask about a release, as a song's release.json keeps it. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct ReleaseDetails { + /// the song's title when empty + pub title: String, + /// a version of the song, such as "Extended Mix" + pub version: String, + pub artist: String, + pub featuring: Vec, + /// the album or EP the song is on; a single when empty + pub album: String, + pub genre: String, + pub subgenre: String, + /// YYYY-MM-DD + pub date: String, + /// the record label: the artist when empty + pub label: String, + /// the songwriters' full legal names + pub writers: Vec, + pub producers: Vec, + /// who owns the recording and the composition, for the ℗ and © lines: the artist when empty + pub owner: String, + pub isrc: String, + pub upc: String, + pub explicit: bool, + /// how the song was made, which distributors ask: `written`, `assisted` or `generated`; None until it is said + pub made: Option, +} + +/// one check of a release, as a store or a distributor would make it +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct ReleaseCheck { + /// `problem` (a store would turn the release down), `warning` or `note` + pub level: String, + /// `artwork`, `audio`, `sounds` or `details` + pub about: String, + pub message: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct ReleaseView { + pub details: ReleaseDetails, + /// the song's own title, which a title in the details replaces + pub song_title: String, + /// what the artwork is ("3000 × 3000 JPEG"), or why it cannot be read; None without artwork + pub artwork: Option, + /// changes with every new artwork, for `/api/release/preview.png?v=` + pub artwork_version: u64, + /// what stores would say of the release with the render `checked` (problems first) + pub checks: Vec, + /// the render the checks are of; 0 until one is checked + pub checked: u64, + /// nothing a store would turn it down for + pub ready: bool, + /// the folder the last release kit went to + pub kit: Option, + /// what `made` may be, with what each means + pub made_options: Vec<(String, String)>, } #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] @@ -300,6 +367,19 @@ pub enum Command { #[serde(default)] path: Option, }, + /// what stores ask about the release, all of it (`ReleaseDetails`), and optionally a JPEG or PNG file for the + /// artwork; the release is checked again + SetRelease { + details: Box, + #[serde(default)] + artwork: Option, + }, + /// write the release kit from the latest render, when it is of the song as it is now and nothing stops the + /// release: into `path` (default: release/ in the song's folder) + WriteKit { + #[serde(default)] + path: Option, + }, SetTempo { bpm: f64, }, diff --git a/crates/claudius-cli/src/publish.rs b/crates/claudius-cli/src/publish.rs index 4bb52d7..011923b 100644 --- a/crates/claudius-cli/src/publish.rs +++ b/crates/claudius-cli/src/publish.rs @@ -103,8 +103,8 @@ pub fn run(r: Request) -> Result<()> { let mut release = publish::load(dir, &bp); apply(&mut release, r.details, r.artwork)?; publish::save(dir, &release)?; - let cover = match &release.artwork { - Some(p) => Some(std::fs::read(p).with_context(|| format!("cannot read the artwork {}", p.display()))?), + let cover = match publish::artwork_file(dir, &release) { + Some(p) => Some(std::fs::read(&p).with_context(|| format!("cannot read the artwork {}", p.display()))?), None => None, }; let master = audio::load_once(&wav).with_context(|| format!("cannot read {}", wav.display()))?; diff --git a/crates/claudius-server/Cargo.toml b/crates/claudius-server/Cargo.toml index ab3ad2b..da309e9 100644 --- a/crates/claudius-server/Cargo.toml +++ b/crates/claudius-server/Cargo.toml @@ -30,6 +30,7 @@ ureq = { version = "3", features = ["json"] } [dev-dependencies] claudius-decode = { version = "0.2.0", path = "../claudius-decode" } +image = { version = "0.25", default-features = false, features = ["png"] } tokio = { version = "1.53.3", features = ["rt-multi-thread", "macros"] } tower = { version = "0.5", features = ["util"] } diff --git a/crates/claudius-server/src/app/commands.rs b/crates/claudius-server/src/app/commands.rs index a5ac3db..0281518 100644 --- a/crates/claudius-server/src/app/commands.rs +++ b/crates/claudius-server/src/app/commands.rs @@ -137,11 +137,14 @@ impl App { bp.bpm, bp.key.name() ); + self.lock().folder = None; self.commit(Some(bp), msg) } Command::Load { path } => self.load(Path::new(&path)), Command::Save { path } => self.save(path.map(PathBuf::from)), Command::Export { formats, path } => self.export(&formats, path.map(PathBuf::from)), + Command::SetRelease { details, artwork } => self.set_release(*details, artwork.map(PathBuf::from)), + Command::WriteKit { path } => self.write_kit(path.map(PathBuf::from)), Command::SetTempo { bpm } => self.run_script(&format!("tempo({bpm})"), Some(format!("tempo {bpm:.0}"))), Command::SetKey { key } => self.run_script(&format!("key({})", quote(&key)), Some(format!("key {key}"))), Command::SetSwing { swing } => { @@ -385,6 +388,7 @@ impl App { } else { String::new() }; + self.lock().folder = None; self.commit(Some(bp), format!("remade {path} as {genre}{moved}:\n{}", analysis_text(&a))) } Command::Mutate { count, keep_best } => { @@ -468,11 +472,20 @@ impl App { None => Ok(()), }); match checked { - Ok(()) => self.commit(Some(bp), format!("loaded {}", path.display())), + Ok(()) => { + self.lock().folder = path.parent().map(Path::to_path_buf); + self.commit(Some(bp), format!("loaded {}", path.display())) + } Err(e) => no(format!("cannot open {}: {e}", path.display()), v), } } else { - self.run_script(&text, Some(format!("loaded {}", path.display()))) + let r = self.run_script(&text, Some(format!("loaded {}", path.display()))); + if r.ok { + // a song folder's song.rhai opens that song; a script elsewhere makes a new one + let folder = path.parent().filter(|d| d.join("blueprint.json").is_file()).map(Path::to_path_buf); + self.update(|s| s.folder = folder); + } + r } } @@ -551,6 +564,9 @@ impl App { return no(format!("cannot write {}: {e}", dir.join(file).display()), v); } } + // the release in the making, too, and the song lives in this folder from now on + self.keep_release(&dir); + self.update(|s| super::release::moved(s, dir.clone())); loop { let dir2 = dir.clone(); if self.start_job("saving", move |app| app.render_job(true, vec![dir2])).ok { diff --git a/crates/claudius-server/src/app/jobs.rs b/crates/claudius-server/src/app/jobs.rs index 61669a8..6a3c0e5 100644 --- a/crates/claudius-server/src/app/jobs.rs +++ b/crates/claudius-server/src/app/jobs.rs @@ -288,6 +288,7 @@ impl App { } } }); + self.check_render(&bp, version, &project, &r.master); self.prune(version); } diff --git a/crates/claudius-server/src/app/mod.rs b/crates/claudius-server/src/app/mod.rs index 140f96b..d36c994 100644 --- a/crates/claudius-server/src/app/mod.rs +++ b/crates/claudius-server/src/app/mod.rs @@ -18,8 +18,10 @@ use crate::views; mod commands; mod jobs; +mod release; pub use commands::analysis_text; +pub use release::{MAX_ARTWORK, report_lines}; pub struct Settings { /// read the Apple Loops / user library for recorded sounds @@ -64,6 +66,10 @@ struct Session { rerender: Option, /// saves asked for while a job ran: one render into all these folders when the job is done pending_saves: Vec, + /// the folder the song was opened from or last saved to + folder: Option, + /// the release in the making for the song + release: Option, } impl Session { @@ -266,7 +272,8 @@ impl App { } pub fn state(&self) -> State { - let s = self.lock(); + let mut s = self.lock(); + let release = self.release_view(&mut s); State { version: s.version, song: s.view.clone(), @@ -283,6 +290,7 @@ impl App { log: s.log.iter().cloned().collect(), genres: self.genres.clone(), exports: self.exports.clone(), + release, } } diff --git a/crates/claudius-server/src/app/release.rs b/crates/claudius-server/src/app/release.rs new file mode 100644 index 0000000..b4ce506 --- /dev/null +++ b/crates/claudius-server/src/app/release.rs @@ -0,0 +1,418 @@ +//! The release the session prepares for the open song, as `claudius publish` does: what stores ask about it, its +//! artwork, what they would say of its latest render, and the release kit. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use claudius::audio::Stereo; +use claudius::model::{Blueprint, Project}; +use claudius::publish::{self, Level, Made, Release}; +use claudius::{audio, compose, config, song}; +use claudius_api::{ReleaseCheck, ReleaseDetails, ReleaseView, Reply}; + +use super::{App, Session, no, ok}; + +/// the largest artwork the studio may send: well past what any store takes +pub const MAX_ARTWORK: usize = 40 << 20; + +/// the side of the artwork's preview +const PREVIEW: u32 = 512; + +/// a song's release in the making +pub(super) struct Prepared { + /// the song it is for: its genre, its seed and its folder, which a new song, a remake or an open changes + song: (String, u64, Option), + details: Release, + artwork: Option>>, + /// the session's version when the artwork came, so that no two pictures share one + artwork_version: u64, + /// what the artwork is, or why it cannot be read + artwork_label: Option, + preview: Option<(u64, Arc>)>, + /// the checks, and the render they are of + report: Option<(u64, publish::Report)>, + kit: Option, + /// something was given for it, or it was read from release.json: saving the song keeps it + given: bool, +} + +impl Prepared { + fn set_artwork(&mut self, bytes: Arc>, version: u64) { + self.artwork_label = Some(match publish::inspect(&bytes) { + Ok(a) => publish::describe(&a), + Err(why) => format!("the file {why}"), + }); + self.artwork = Some(bytes); + self.artwork_version = version; + self.preview = None; + } +} + +/// the open song lives in `dir` now (it was saved there): its release stays as it is +pub(super) fn moved(s: &mut Session, dir: PathBuf) { + if let Some(p) = s.release.as_mut() { + p.song.2 = Some(dir.clone()); + } + s.folder = Some(dir); +} + +/// where Save puts a song that has no folder yet +pub(super) fn default_folder(bp: &Blueprint) -> PathBuf { + config::songs_dir().join(format!("{}-{}-{}", bp.genre, song::slug(&bp.title), bp.seed)) +} + +fn to_api(r: &Release) -> ReleaseDetails { + ReleaseDetails { + title: r.title.clone(), + version: r.version.clone(), + artist: r.artist.clone(), + featuring: r.featuring.clone(), + album: r.album.clone(), + genre: r.genre.clone(), + subgenre: r.subgenre.clone(), + date: r.date.clone(), + label: r.label.clone(), + writers: r.writers.clone(), + producers: r.producers.clone(), + owner: r.owner.clone(), + isrc: r.isrc.clone(), + upc: r.upc.clone(), + explicit: r.explicit, + made: r.made.map(|m| m.name().to_string()), + } +} + +fn from_api(d: ReleaseDetails, artwork: Option) -> Result { + let made = d.made.as_deref().filter(|m| !m.trim().is_empty()).map(str::parse::).transpose(); + let names = |v: Vec| -> Vec { + v.into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + }; + Ok(Release { + title: d.title.trim().into(), + version: d.version.trim().into(), + artist: d.artist.trim().into(), + featuring: names(d.featuring), + album: d.album.trim().into(), + genre: d.genre.trim().into(), + subgenre: d.subgenre.trim().into(), + date: d.date.trim().into(), + label: d.label.trim().into(), + writers: names(d.writers), + producers: names(d.producers), + owner: d.owner.trim().into(), + isrc: d.isrc.trim().into(), + upc: d.upc.trim().into(), + explicit: d.explicit, + made: made.map_err(|e| format!("{e:#}"))?, + artwork, + }) +} + +fn level(l: Level) -> &'static str { + match l { + Level::Problem => "problem", + Level::Warning => "warning", + Level::Note => "note", + } +} + +/// a report as lines, problems marked `--`, warnings `!` and notes `..`, as `claudius publish` prints them +pub fn report_lines(checks: &[ReleaseCheck]) -> String { + checks + .iter() + .map(|c| { + let mark = match c.level.as_str() { + "problem" => "--", + "warning" => " !", + _ => "..", + }; + format!("{mark} {}: {}", c.about, c.message) + }) + .collect::>() + .join("\n") +} + +impl App { + /// the release of the open song, read from its folder's release.json (or begun from the songs next to it) + /// when the song is new to it + pub(super) fn prepared<'a>(&self, s: &'a mut Session) -> Option<&'a mut Prepared> { + let bp = s.bp.as_ref()?; + let key = (bp.genre.clone(), bp.seed, s.folder.clone()); + if s.release.as_ref().is_none_or(|p| p.song != key) { + let dir = s.folder.clone().unwrap_or_else(|| default_folder(bp)); + let mut details = publish::load(&dir, bp); + // the song's own title, which follows the song as it changes, unless one was given + if details.title == bp.title && !dir.join(publish::FILE).exists() { + details.title.clear(); + } + let artwork = publish::artwork_file(&dir, &details) + .filter(|f| config::path_allowed(f, self.roots.as_deref())) + .and_then(|f| std::fs::read(f).ok()); + let mut p = Prepared { + song: key, + details, + artwork: None, + artwork_version: 0, + artwork_label: None, + preview: None, + report: None, + kit: None, + given: dir.join(publish::FILE).is_file(), + }; + if let Some(a) = artwork { + p.set_artwork(Arc::new(a), s.version); + } + s.release = Some(p); + } + s.release.as_mut() + } + + pub(super) fn release_view(&self, s: &mut Session) -> Option { + let title = s.bp.as_ref()?.title.clone(); + let p = self.prepared(s)?; + let report = p.report.as_ref(); + Some(ReleaseView { + details: to_api(&p.details), + song_title: title, + artwork: p.artwork_label.clone(), + artwork_version: p.artwork_version, + checks: report + .map(|(_, r)| { + r.checks + .iter() + .map(|c| ReleaseCheck { + level: level(c.level).into(), + about: c.about.name().into(), + message: c.message.clone(), + }) + .collect() + }) + .unwrap_or_default(), + checked: report.map_or(0, |(v, _)| *v), + ready: report.is_some_and(|(_, r)| r.ready()), + kit: p.kit.as_ref().map(|k| k.display().to_string()), + made_options: Made::ALL.iter().map(|m| (m.name().to_string(), m.describe().to_string())).collect(), + }) + } + + pub(super) fn set_release(self: &Arc, details: ReleaseDetails, artwork: Option) -> Reply { + let v = self.version(); + let bytes = match &artwork { + Some(p) => { + if let Err(e) = self.readable(p) { + return no(e, v); + } + match std::fs::read(p) { + Ok(b) if b.len() > MAX_ARTWORK => return no(format!("{} is over 40 MB", p.display()), v), + Ok(b) => Some(Arc::new(b)), + Err(e) => return no(format!("cannot read {}: {e}", p.display()), v), + } + } + None => None, + }; + let set = self.try_update(|s| { + let version = s.version + 1; + let p = self.prepared(s).ok_or_else(|| "no song to release: make one first".to_string())?; + p.details = from_api(details, artwork.clone().or_else(|| p.details.artwork.clone()))?; + p.given = true; + if let Some(b) = bytes { + p.set_artwork(b, version); + } + Ok::<_, String>(()) + }); + if let Err(e) = set { + return no(e, v); + } + self.recheck(); + ok(self.release_summary(), self.version()) + } + + /// the artwork, as the studio sends it + pub fn set_artwork_bytes(self: &Arc, bytes: Vec) -> Reply { + let v = self.version(); + if bytes.len() > MAX_ARTWORK { + return no("the artwork is over 40 MB", v); + } + let set = self.try_update(|s| { + let version = s.version + 1; + let p = self.prepared(s).ok_or_else(|| "no song to release: make one first".to_string())?; + p.set_artwork(Arc::new(bytes), version); + p.details.artwork = None; + p.given = true; + Ok::<_, String>(p.artwork_label.clone().unwrap_or_default()) + }); + match set { + Ok(label) => { + self.recheck(); + ok(format!("artwork: {label}"), self.version()) + } + Err(e) => no(e, v), + } + } + + /// the artwork as it was given + pub fn artwork(&self) -> Option>> { + self.lock().release.as_ref()?.artwork.clone() + } + + /// the artwork at most 512 pixels on a side, as PNG (made once for each artwork) + pub fn artwork_preview(&self) -> Option>> { + let (bytes, version) = { + let s = self.lock(); + let p = s.release.as_ref()?; + if let Some((v, png)) = &p.preview + && *v == p.artwork_version + { + return Some(png.clone()); + } + (p.artwork.clone()?, p.artwork_version) + }; + let png = Arc::new(publish::preview(&bytes, PREVIEW)?); + let mut s = self.lock(); + if let Some(p) = s.release.as_mut().filter(|p| p.artwork_version == version) { + p.preview = Some((version, png.clone())); + } + Some(png) + } + + fn release_summary(&self) -> String { + let mut s = self.lock(); + let Some(view) = self.release_view(&mut s) else { return "no song".into() }; + if view.checked == 0 { + return "release details kept; they are checked once the song is rendered".into(); + } + let problems = view.checks.iter().filter(|c| c.level == "problem").count(); + let state = if problems == 0 { + "ready for a distributor".to_string() + } else { + format!("{problems} problem(s) to fix before a distributor takes it") + }; + format!("{state}\n{}", report_lines(&view.checks)) + } + + /// check the release again, against the latest render when it is of the song as it is now + pub(super) fn recheck(self: &Arc) { + let (bp, version) = { + let s = self.lock(); + let Some(bp) = s.bp.clone() else { return }; + let current = s.render.as_ref().map(|r| r.view.version).filter(|v| *v >= s.song_version); + let Some(version) = current else { return }; + (bp, version) + }; + let Ok(master) = audio::load_once(&self.cache.join(format!("{version}.wav"))) else { return }; + self.check_render(&bp, version, &compose::arrange(&bp), &master); + } + + /// what stores would say of render `version` of `bp`, kept with the release, once one is in the making: until + /// something is given for it, a render has no checks to pay for + pub(super) fn check_render(&self, bp: &Blueprint, version: u64, project: &Project, master: &Stereo) { + let (details, artwork, artwork_version) = { + let s = self.lock(); + let Some(p) = s.release.as_ref().filter(|p| p.given) else { return }; + (p.details.clone(), p.artwork.clone(), p.artwork_version) + }; + let report = + publish::check(&details, &publish::Song { bp, project, master }, artwork.as_deref().map(|a| &a[..])); + self.update(|s| { + // what changed meanwhile is checked again by whatever changed it + if let Some(p) = s.release.as_mut().filter(|p| p.details == details && p.artwork_version == artwork_version) + { + p.report = Some((version, report)); + } + }); + } + + pub(super) fn write_kit(self: &Arc, path: Option) -> Reply { + let v = self.version(); + let Some(bp) = self.blueprint() else { return no("no song to release", v) }; + let (version, details, artwork, folder) = { + let mut s = self.lock(); + let current = s.render.as_ref().map(|r| r.view.version).filter(|rv| *rv >= s.song_version); + let folder = s.folder.clone(); + let Some(p) = self.prepared(&mut s) else { return no("no song to release", v) }; + (current, p.details.clone(), p.artwork.clone(), folder) + }; + let Some(version) = version else { + return no("the song has changed since its last render: render it, then write the kit", v); + }; + let Some(artwork) = artwork else { + return no("add the artwork first: stores need a square JPEG or PNG, 3000 × 3000", v); + }; + let song_dir = folder.unwrap_or_else(|| default_folder(&bp)); + let dir = path.unwrap_or_else(|| song_dir.join("release")); + for d in [&dir, &song_dir] { + if !config::write_allowed(d, self.roots.as_deref()) { + return no(format!("{} is outside the folders this claudius may write", d.display()), v); + } + } + let master = match audio::load_once(&self.cache.join(format!("{version}.wav"))) { + Ok(m) => m, + Err(e) => return no(format!("cannot read the render: {e:#}"), v), + }; + let project = compose::arrange(&bp); + let song = publish::Song { bp: &bp, project: &project, master: &master }; + let report = publish::check(&details, &song, Some(&artwork)); + if !report.ready() { + let first = report.checks.iter().find(|c| c.level == Level::Problem).map(|c| c.message.clone()); + let n = report.problems(); + self.update(|s| { + if let Some(p) = s.release.as_mut() { + p.report = Some((version, report)); + } + }); + return no(format!("{n} problem(s) to fix first: {}", first.unwrap_or_default()), self.version()); + } + let files = match publish::write_kit(&dir, &details, &song, &artwork, &report) { + Ok(f) => f, + Err(e) => return no(format!("cannot write the release kit: {e:#}"), v), + }; + // the details and the artwork go with the song, for the next time it is opened + let kept = keep(&song_dir, &details, Some(&artwork)); + let names: Vec = + files.iter().filter_map(|(f, _)| f.file_name().map(|n| n.to_string_lossy().into_owned())).collect(); + let msg = format!("wrote the release kit to {}: {}", dir.display(), names.join(", ")); + self.update(|s| { + if let Some(p) = s.release.as_mut() { + p.report = Some((version, report)); + p.kit = Some(dir.clone()); + if let Ok(Some(name)) = &kept { + p.details.artwork = Some(name.clone()); + } + } + s.say(msg.clone()); + if let Err(e) = &kept { + s.say(format!("could not keep the release details with the song: {e:#}")); + } + }); + ok(msg, self.version()) + } + + /// keep the release's details and artwork in the folder a song is saved to + pub(super) fn keep_release(&self, dir: &Path) { + let (details, artwork) = { + let s = self.lock(); + let Some(p) = s.release.as_ref().filter(|p| p.given) else { return }; + (p.details.clone(), p.artwork.clone()) + }; + let kept = keep(dir, &details, artwork.as_deref().map(|a| &a[..])); + self.update(|s| match &kept { + Ok(name) => { + if let Some(p) = s.release.as_mut() { + p.details.artwork = name.clone(); + } + } + Err(e) => s.say(format!("could not keep the release details with the song: {e:#}")), + }); + } +} + +/// release.json in a song's folder, with the artwork beside it when there is one; returns the artwork's name there +fn keep(dir: &Path, details: &Release, artwork: Option<&[u8]>) -> anyhow::Result> { + std::fs::create_dir_all(dir)?; + let name = match artwork { + Some(a) => Some(publish::save_artwork(dir, a)?), + None => details.artwork.clone(), + }; + publish::save(dir, &Release { artwork: name.clone(), ..details.clone() })?; + Ok(name) +} diff --git a/crates/claudius-server/src/http.rs b/crates/claudius-server/src/http.rs index b9ee3c5..f8ebcaa 100644 --- a/crates/claudius-server/src/http.rs +++ b/crates/claudius-server/src/http.rs @@ -61,6 +61,11 @@ pub fn router(app: Arc, o: &Options) -> Router { .route("/api/audio/{file}", get(audio)) .route("/api/stems/{version}/{file}", get(stem)) .route("/api/export/{version}/{file}", get(export_file)) + .route( + "/api/release/artwork", + get(artwork).post(set_artwork).layer(DefaultBodyLimit::max(crate::app::MAX_ARTWORK)), + ) + .route("/api/release/preview.png", get(artwork_preview)) .with_state(ctx); let mut r = api.nest_service("/mcp", crate::mcp::http_service(app, o.public)); r = match &o.web { @@ -331,6 +336,48 @@ async fn export_file( } } +/// the release's artwork, as the file itself: a picture's type, so that a cross-site form cannot send one +async fn set_artwork(State(c): State, headers: HeaderMap, body: Bytes) -> Response { + let picture = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("image/") || v.starts_with("application/octet-stream")); + if !picture { + return ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "send the artwork as the file itself, with Content-Type image/jpeg, image/png or application/octet-stream", + ) + .into_response(); + } + let app = c.app.clone(); + let reply = tokio::task::spawn_blocking(move || app.set_artwork_bytes(body.to_vec())) + .await + .unwrap_or_else(|e| claudius_api::Reply { ok: false, message: format!("internal error: {e}"), version: 0 }); + Json(reply).into_response() +} + +async fn artwork(State(c): State) -> Response { + let Some(bytes) = c.app.artwork() else { + return (StatusCode::NOT_FOUND, "the release has no artwork yet").into_response(); + }; + let mime = if bytes.starts_with(b"\x89PNG") { + "image/png" + } else if bytes.starts_with(&[0xFF, 0xD8]) { + "image/jpeg" + } else { + "application/octet-stream" + }; + ([(header::CONTENT_TYPE, mime)], bytes.as_ref().clone()).into_response() +} + +async fn artwork_preview(State(c): State) -> Response { + let app = c.app.clone(); + match tokio::task::spawn_blocking(move || app.artwork_preview()).await.ok().flatten() { + Some(png) => ([(header::CONTENT_TYPE, "image/png")], png.as_ref().clone()).into_response(), + None => (StatusCode::NOT_FOUND, "no artwork that reads as a picture").into_response(), + } +} + async fn no_ui() -> Html<&'static str> { Html( "claudius\ diff --git a/crates/claudius-server/src/mcp.rs b/crates/claudius-server/src/mcp.rs index 175fdba..2f33609 100644 --- a/crates/claudius-server/src/mcp.rs +++ b/crates/claudius-server/src/mcp.rs @@ -256,6 +256,63 @@ pub struct ExportArgs { pub path: Option, } +/// What stores ask about a release. Give only what the person told you; what is left out stays as it was. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub struct PublishArgs { + /// the name the song comes out under + #[serde(default)] + pub artist: Option, + /// the title, when it is not the song's + #[serde(default)] + pub title: Option, + /// a version of the song, such as "Extended Mix" or "Radio Edit" + #[serde(default)] + pub version: Option, + #[serde(default)] + pub featuring: Option>, + /// the album or EP the song is on; without one it is a single + #[serde(default)] + pub album: Option, + /// the store genre ("Dance", "Electronic", "Hip-Hop/Rap") and subgenre + #[serde(default)] + pub genre: Option, + #[serde(default)] + pub subgenre: Option, + /// the release date, YYYY-MM-DD + #[serde(default)] + pub date: Option, + /// the record label (the artist when there is none) + #[serde(default)] + pub label: Option, + /// the songwriters' full legal names + #[serde(default)] + pub writers: Option>, + #[serde(default)] + pub producers: Option>, + /// who owns the recording and the composition, for the ℗ and © lines (the artist when empty) + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub isrc: Option, + #[serde(default)] + pub upc: Option, + #[serde(default)] + pub explicit: Option, + /// how the song was made, in the person's own answer: written (they wrote it), assisted (you or claudius' + /// generator wrote some of it and they shaped it) or generated (you or the generator wrote it). Never choose it + #[serde(default)] + pub made: Option, + /// the artwork file the person gave: a square JPEG or PNG, 3000 × 3000 + #[serde(default)] + pub artwork: Option, + /// write the release kit (without it, only check) + #[serde(default)] + pub write: bool, + /// the kit's folder (default: release/ in the song's folder) + #[serde(default)] + pub path: Option, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct RemakeArgs { /// the audio file to learn from @@ -496,6 +553,69 @@ impl Studio { if r.ok { Ok(r.message) } else { Err(r.message) } } + #[tool( + description = "Check the song for release as stores and distributors do (its artwork, its audio, the licences of its sounds, its details), and with write, write a release kit: the WAVs and the cover a distributor takes, and release.txt with the answers its form asks for. Give only what the person told you: the artist, the songwriters' full legal names, the artwork file, and how the song was made, which you must never choose for them. Renders first when the song changed. Problems (--) stop the kit." + )] + async fn publish(&self, Parameters(a): Parameters) -> Result { + let st = self.b.state().await?; + let mut d = st.release.map(|r| r.details).ok_or("no song to release: make one first")?; + let set = |field: &mut String, v: Option| { + if let Some(v) = v { + *field = v; + } + }; + set(&mut d.artist, a.artist); + set(&mut d.title, a.title); + set(&mut d.version, a.version); + set(&mut d.album, a.album); + set(&mut d.genre, a.genre); + set(&mut d.subgenre, a.subgenre); + set(&mut d.date, a.date); + set(&mut d.label, a.label); + set(&mut d.owner, a.owner); + set(&mut d.isrc, a.isrc); + set(&mut d.upc, a.upc); + for (list, given) in + [(&mut d.featuring, a.featuring), (&mut d.writers, a.writers), (&mut d.producers, a.producers)] + { + if let Some(g) = given { + *list = g; + } + } + if let Some(e) = a.explicit { + d.explicit = e; + } + if a.made.is_some() { + d.made = a.made; + } + let r = self.b.command(Command::SetRelease { details: Box::new(d), artwork: a.artwork }).await?; + if !r.ok { + return Err(r.message); + } + let st = match st.render.as_ref().is_some_and(|r| r.version >= st.song_version) { + true => self.b.state().await?, + false => self.heard(false).await?, + }; + let release = st.release.ok_or("no song to release")?; + let problems = release.checks.iter().filter(|c| c.level == "problem").count(); + let mut text = match (release.checked, problems) { + (0, _) => "not checked yet: the song has no render".to_string(), + (_, 0) => "ready for a distributor".to_string(), + (_, n) => format!("{n} problem(s), marked --, to fix before a distributor takes it"), + }; + if !release.checks.is_empty() { + text = format!("{text}\n{}", crate::app::report_lines(&release.checks)); + } + if a.write { + let r = self.b.command(Command::WriteKit { path: a.path }).await?; + text = format!("{text}\n\n{}", r.message); + if !r.ok { + return Err(text); + } + } + Ok(text) + } + #[tool(description = "Open a saved song: a folder or blueprint.json written by save, or a .rhai script.")] async fn open(&self, Parameters(a): Parameters) -> Result { self.edit(Command::Load { path: a.path }, true).await diff --git a/crates/claudius-server/tests/session.rs b/crates/claudius-server/tests/session.rs index 54d5fee..04b0275 100644 --- a/crates/claudius-server/tests/session.rs +++ b/crates/claudius-server/tests/session.rs @@ -96,6 +96,11 @@ async fn a_shared_session_keeps_to_its_folders() { let r = app .command(Command::RunScript { code: format!("track(\"v\", note(\"c4\").s({:?}))", wav.display().to_string()) }); assert!(!r.ok && r.message.contains("outside the folders"), "{}", r.message); + let r = app.command(Command::SetRelease { + details: Default::default(), + artwork: Some(outside.join("x.wav").display().to_string()), + }); + assert!(!r.ok && r.message.contains("outside the folders"), "artwork is read as any file is: {}", r.message); std::fs::remove_dir_all(&inside).ok(); std::fs::remove_dir_all(&outside).ok(); } @@ -260,6 +265,7 @@ async fn assistants_work_over_mcp() { "script_reference", "save", "export", + "publish", ] { assert!(tools.iter().any(|x| x == t), "{t} missing from {tools:?}"); } @@ -276,6 +282,14 @@ async fn assistants_work_over_mcp() { assert_ne!(r["result"]["isError"], true, "{b}"); assert!(text.contains("techno") && text.contains("verdict"), "{text}"); + // checked for release, it has no artwork, and nobody has said how it was made + let (_, _, b) = call(&router, call_tool(99, "publish", serde_json::json!({"artist": "Ana Lima"}))).await; + let text = sse_json(&b)["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + assert!( + text.contains("problem(s)") && text.contains("-- artwork") && text.contains("how the song was made"), + "{text}" + ); + let (_, _, b) = call( &router, call_tool(4, "run_script", serde_json::json!({"code": "track(\"x\", s(\"nope*4\"))", "listen": false})), @@ -357,6 +371,91 @@ async fn a_render_exports_by_command_and_as_a_download() { std::fs::remove_dir_all(&dir).ok(); } +/// a square cover with hard edges, as a PNG +fn cover(side: u32) -> Vec { + let img = image::RgbImage::from_fn(side, side, |x, y| { + let v = if (x / 120 + y / 120).is_multiple_of(2) { 40 } else { 210 }; + image::Rgb([v, 90, 255 - v]) + }); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_release_is_checked_and_its_kit_written_and_kept_with_the_song() { + let app = app(); + let new = Command::NewSong { genre: "lofi".into(), seed: Some(6), bpm: None, key: None, loops: false }; + assert!(app.command(new).ok); + // a folder of the test's own: a song without one would release into ~/Music/claudius + let dir = std::env::temp_dir().join(format!("claudius-release-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + assert!(app.command(Command::Save { path: Some(dir.display().to_string()) }).ok); + let st = app.settled(Duration::from_secs(240)).await; + let release = st.release.expect("a release for the song"); + assert_eq!(release.song_title, st.song.as_ref().unwrap().title); + assert!(release.made_options.iter().any(|(m, _)| m == "assisted")); + assert_eq!(release.checked, 0, "renders check a release only once something is given for it"); + assert!(!dir.join("release.json").exists(), "nor does saving the song keep one"); + + let details = claudius_api::ReleaseDetails { + artist: "Ana Lima".into(), + writers: vec!["Ana Lima".into()], + made: Some("assisted".into()), + ..release.details + }; + let r = app.command(Command::SetRelease { details: Box::new(details), artwork: None }); + assert!(r.ok, "{}", r.message); + let release = app.state().release.unwrap(); + assert!(!release.ready && release.checked > 0); + let problems: Vec<&str> = + release.checks.iter().filter(|c| c.level == "problem").map(|c| c.about.as_str()).collect(); + assert_eq!(problems, ["artwork"], "{:?}", release.checks); + let r = app.command(Command::WriteKit { path: None }); + assert!(!r.ok && r.message.contains("add the artwork"), "{}", r.message); + + // the artwork comes as the file itself + let router = http::router(app.clone(), &Options::default()); + let post = |content_type: &str| { + Request::post("/api/release/artwork") + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, content_type) + .body(Body::from(cover(3000))) + .unwrap() + }; + let (s, _, _) = call(&router, post("text/plain")).await; + assert_eq!(s, StatusCode::UNSUPPORTED_MEDIA_TYPE, "a cross-site form cannot send artwork"); + let (s, _, b) = call(&router, post("image/png")).await; + assert!(s == StatusCode::OK && b.contains("3000 × 3000 PNG"), "{s} {b}"); + let release = app.state().release.unwrap(); + assert!(release.ready, "{:?}", release.checks); + let get = |path: &str| Request::get(path).header(header::HOST, "localhost").body(Body::empty()).unwrap(); + let res = router.clone().oneshot(get("/api/release/preview.png")).await.unwrap(); + assert_eq!(res.headers()[header::CONTENT_TYPE], "image/png"); + let png = axum::body::to_bytes(res.into_body(), 1 << 24).await.unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().width(), 512); + + // the kit goes into the song's folder, and the details and the artwork stay with the song + let r = app.command(Command::WriteKit { path: None }); + assert!(r.ok, "{}", r.message); + assert!(dir.join("release/release.txt").is_file() && dir.join("artwork.png").is_file()); + let kept: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("release.json")).unwrap()).unwrap(); + assert_eq!((kept["made"].as_str(), kept["artwork"].as_str()), (Some("assisted"), Some("artwork.png"))); + assert!(app.state().release.unwrap().kit.is_some_and(|k| k.ends_with("release"))); + + // another song starts afresh, and opening this one again brings its release back + let new = Command::NewSong { genre: "house".into(), seed: Some(3), bpm: None, key: None, loops: false }; + assert!(app.command(new).ok); + assert_eq!(app.state().release.unwrap().artwork, None); + assert!(app.command(Command::Load { path: dir.display().to_string() }).ok); + let release = app.state().release.unwrap(); + assert_eq!(release.details.artist, "Ana Lima"); + assert_eq!(release.artwork.as_deref(), Some("3000 × 3000 PNG")); + app.settled(Duration::from_secs(240)).await; + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn the_studio_reads_and_writes_steps_as_scripts_do() { use claudius::theory::rhythm::Hit; diff --git a/crates/claudius-ui/Cargo.toml b/crates/claudius-ui/Cargo.toml index 4338dcd..505e6ee 100644 --- a/crates/claudius-ui/Cargo.toml +++ b/crates/claudius-ui/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" claudius-api = { path = "../claudius-api" } eframe = { version = "0.37", default-features = false, features = ["default_fonts", "glow", "clipboard", "links", "persistence", "wayland", "x11"] } ehttp = "0.7" +image = { version = "0.25", default-features = false, features = ["png"] } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/claudius-ui/src/app.rs b/crates/claudius-ui/src/app.rs index 5e8af6e..43f36cc 100644 --- a/crates/claudius-ui/src/app.rs +++ b/crates/claudius-ui/src/app.rs @@ -212,7 +212,10 @@ impl Studio { } } let text = if reply.message.is_empty() { what.replace('_', " ") } else { reply.message }; - self.toast(text, reply.ok, now); + // the Publish dialog shows what the checks say, field after field + if !(reply.ok && what == "set_release") { + self.toast(text, reply.ok, now); + } } Err(e) => { if script { @@ -233,6 +236,7 @@ impl Studio { self.view.reference = Some(result.unwrap_or_else(|e| format!("# The reference is not available\n\n{e}"))); } + Event::Preview { version, image } => self.view.publish.arrived = Some((version, image)), } } @@ -410,6 +414,7 @@ impl Studio { self.view.tour_asks = self.native.as_ref().is_some_and(|n| n.statistics().is_none()); } Step::Assistants => self.view.assistants.show(), + Step::Publish => self.view.publish.show(), Step::AudioPath(p) => self.view.audio_path = p, Step::Exit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } @@ -906,6 +911,17 @@ impl eframe::App for Studio { now, }; panels::transport::new_song_dialog(&ctx, &mut cx); + panels::publish::dialog(&ctx, &mut cx); + } + if let Some(bytes) = self.view.publish.upload.take() { + self.inflight += 1; + self.inflight_since = now; + self.backend.upload_artwork(bytes); + } + if let Some(release) = &self.state.release + && let Some(v) = self.view.publish.wants_preview(release) + { + self.backend.fetch_preview(v); } panels::assistants::dialog(&ctx, &mut self.view.assistants, self.native.as_ref()); // the first time, once the studio is up; a scripted run starts it only when asked diff --git a/crates/claudius-ui/src/devplan.rs b/crates/claudius-ui/src/devplan.rs index 8402aee..d0facd5 100644 --- a/crates/claudius-ui/src/devplan.rs +++ b/crates/claudius-ui/src/devplan.rs @@ -1,7 +1,7 @@ //! A scripted run for checking the studio without a person at the screen (native only): env `CLAUDIUS_UI_PLAN` is a //! `;`-separated list of steps, e.g. `wait:4;shot:/tmp/a.png;tab:mixer;play;wait:2;shot:/tmp/b.png;exit`. //! Steps: `wait:`, `tab:`, `section:`, `play`, `pause`, `seek:`, -//! `newsong`, `close`, `tour[:]`, `assistants`, `cmd:`, `size:,`, `script:` (into the editor), `run`, `audiopath:` +//! `newsong`, `close`, `tour[:]`, `assistants`, `publish`, `cmd:`, `size:,`, `script:` (into the editor), `run`, `audiopath:` //! (the editor's script), `reference`, `shot:` (the window as rendered), `exit`, and synthetic pointer input in //! points: `move:x,y`, `click:x,y`, `rclick:x,y`, `drag:x1,y1,x2,y2`, `sdrag:x1,y1,x2,y2` (with shift). @@ -30,6 +30,8 @@ pub enum Step { Tour(usize), /// the Connect an assistant dialog Assistants, + /// the Publish dialog + Publish, Exit, } @@ -124,6 +126,7 @@ impl Plan { "newsong" => Some(Step::NewSong), "tour" => Some(Step::Tour(arg.parse().unwrap_or(0))), "assistants" => Some(Step::Assistants), + "publish" => Some(Step::Publish), "close" => Some(Step::Close), "cmd" => match serde_json::from_str(arg) { Ok(c) => Some(Step::Send(c)), diff --git a/crates/claudius-ui/src/mock/mod.rs b/crates/claudius-ui/src/mock/mod.rs index a77bdda..d982429 100644 --- a/crates/claudius-ui/src/mock/mod.rs +++ b/crates/claudius-ui/src/mock/mod.rs @@ -8,7 +8,7 @@ mod synth; use std::sync::Arc; -use claudius_api::{Command, RenderView, Reply, SongView, State}; +use claudius_api::{Command, ReleaseCheck, ReleaseDetails, ReleaseView, RenderView, Reply, SongView, State}; use crate::audio::Pcm; use crate::edits; @@ -89,6 +89,25 @@ const REFERENCE: &str = "# claudius script (demo)\n\nThe demo has no script engi | `mix(\"lead\", #{ level: -3, solo: true })` | mix a track |\n| `track(\"hats\", s(\"[~ hh]*4\").gain(0.6))` | a pattern track |\n\n\ ```js\ntrack(\"keys\", chord(\"prog\").voicing().s(\"rhodes\"))\ns(\"bd*4\").bank(\"tr909\")\n```\n"; +/// the demo's release: what `claudius publish` asks, and a note that nothing is checked here +fn demo_release() -> ReleaseView { + let made = [ + ("written", "you wrote it: the notes, the parts and the arrangement, with claudius as the instrument"), + ("assisted", "written with help: an assistant or claudius' generator wrote some of it, and you shaped it"), + ("generated", "an assistant or claudius' generator wrote it"), + ]; + ReleaseView { + details: ReleaseDetails { genre: "Dance".into(), subgenre: "House".into(), ..Default::default() }, + checks: vec![ReleaseCheck { + level: "note".into(), + about: "details".into(), + message: "the demo checks nothing and writes no files: start `claudius serve` to publish".into(), + }], + made_options: made.iter().map(|(m, d)| (m.to_string(), d.to_string())).collect(), + ..Default::default() + } +} + impl Mock { pub fn new() -> Mock { let song = song::generate("house", 7, None, Some("D minor"), false); @@ -101,6 +120,7 @@ impl Mock { "claudius demo: no server, a built-in mock plays its part".into(), "generated: house seed 7".into(), ], + release: Some(demo_release()), ..Default::default() }; Mock { @@ -140,7 +160,16 @@ impl Mock { } } + /// a reply that turns down what the demo cannot do + pub fn refuse(&mut self, what: &str, why: &str) { + let reply = Reply { ok: false, message: why.into(), version: self.state.version }; + self.events.push(Event::Reply { what: what.into(), result: Ok(reply) }); + } + fn publish(&mut self) { + if let (Some(r), Some(s)) = (self.state.release.as_mut(), self.state.song.as_ref()) { + r.song_title = s.title.clone(); + } self.state.version += 1; if std::mem::take(&mut self.song_changed) { self.state.song_version = self.state.version; @@ -222,6 +251,15 @@ impl Mock { Ok(msg) } Command::Export { .. } => Err("the demo cannot export: start `claudius serve` for that".into()), + Command::SetRelease { details, .. } => { + if let Some(r) = self.state.release.as_mut() { + r.details = details.as_ref().clone(); + } + Ok("kept: the demo checks nothing".into()) + } + Command::WriteKit { .. } => { + Err("the demo cannot write a release kit: start `claudius serve` for that".into()) + } Command::Analyze { path } => match audio_file(path) { Ok(()) => { let msg = self.fake_analysis(path); diff --git a/crates/claudius-ui/src/net.rs b/crates/claudius-ui/src/net.rs index f179633..c388e68 100644 --- a/crates/claudius-ui/src/net.rs +++ b/crates/claudius-ui/src/net.rs @@ -103,6 +103,11 @@ pub enum Event { }, /// the script reference (markdown) Reference(Result), + /// the release's artwork as a picture to show, for `ReleaseView.artwork_version` + Preview { + version: u64, + image: Result, + }, } /// the `"cmd"` tag of a command ("set_tempo") @@ -225,6 +230,43 @@ impl Remote { }); } + fn upload_artwork(&mut self, bytes: Vec) { + let mut request = ehttp::Request::post(self.url("/api/release/artwork"), bytes); + request.headers = + ehttp::Headers::new(&[("Accept", "application/json"), ("Content-Type", "application/octet-stream")]); + let (tx, ctx) = (self.tx.clone(), self.ctx.clone()); + ehttp::fetch(request, move |res| { + let result = match res { + Ok(r) => { + serde_json::from_slice::(&r.bytes).map_err(|_| format!("{} {}", r.status, r.status_text)) + } + Err(e) => Err(e), + }; + Self::deliver(&tx, &ctx, Event::Reply { what: "artwork".into(), result }); + }); + } + + fn fetch_preview(&self, version: u64) { + let (tx, ctx) = (self.tx.clone(), self.ctx.clone()); + let url = self.url(&format!("/api/release/preview.png?v={version}")); + ehttp::fetch(ehttp::Request::get(url), move |res| { + let image = match res { + Ok(r) if r.ok => image::load_from_memory_with_format(&r.bytes, image::ImageFormat::Png) + .map(|img| { + let rgba = img.into_rgba8(); + egui::ColorImage::from_rgba_unmultiplied( + [rgba.width() as usize, rgba.height() as usize], + rgba.as_raw(), + ) + }) + .map_err(|e| e.to_string()), + Ok(r) => Err(format!("{} {}", r.status, r.status_text)), + Err(e) => Err(e), + }; + Self::deliver(&tx, &ctx, Event::Preview { version, image }); + }); + } + fn send(&mut self, cmd: &Command) { if matches!(cmd, Command::InstallPacks { .. }) { self.sounds_after_install = Some(false); @@ -340,6 +382,21 @@ impl Backend { } } + /// send the release's artwork, the file itself (the answer arrives as a reply to "artwork") + pub fn upload_artwork(&mut self, bytes: Vec) { + match self { + Backend::Remote(r) => r.upload_artwork(bytes), + Backend::Mock(m) => m.refuse("artwork", "the demo keeps no artwork: start `claudius serve` to publish"), + } + } + + /// ask for the artwork as a picture to show (arrives as `Event::Preview`) + pub fn fetch_preview(&mut self, version: u64) { + if let Backend::Remote(r) = self { + r.fetch_preview(version); + } + } + /// ask for the script reference (arrives as `Event::Reference`) pub fn fetch_reference(&mut self) { match self { diff --git a/crates/claudius-ui/src/panels/mod.rs b/crates/claudius-ui/src/panels/mod.rs index f8ee79a..6c98b59 100644 --- a/crates/claudius-ui/src/panels/mod.rs +++ b/crates/claudius-ui/src/panels/mod.rs @@ -10,6 +10,7 @@ pub mod log; pub mod markdown; pub mod mixer; pub mod parts; +pub mod publish; pub mod script; pub mod sequencer; pub mod transport; @@ -163,6 +164,7 @@ pub struct View { pub volume: f32, pub sevenths: bool, pub assistants: assistants::Assistants, + pub publish: publish::Publish, /// the tour's step on screen, and whether it was taken or skipped (kept between runs) pub tour: Option, pub tour_done: bool, @@ -219,6 +221,7 @@ impl Default for View { volume: 0.9, sevenths: true, assistants: assistants::Assistants::default(), + publish: publish::Publish::default(), tour: None, tour_done: false, tour_asks: false, diff --git a/crates/claudius-ui/src/panels/publish.rs b/crates/claudius-ui/src/panels/publish.rs new file mode 100644 index 0000000..4547cda --- /dev/null +++ b/crates/claudius-ui/src/panels/publish.rs @@ -0,0 +1,394 @@ +//! "Publish": the release the session prepares for the song, as `claudius publish` does on the command line. The +//! artwork is dropped onto the studio (or, natively, read from a path), each detail is sent when its field is left, +//! and the checks come back with the state: what stores and distributors would say of the latest render. + +use claudius_api::{Command, ReleaseDetails, ReleaseView}; +use eframe::egui::{self, Color32, RichText, Sense, Stroke, StrokeKind, Ui, vec2}; + +use super::{Cx, small}; +use crate::theme::{self, TEXT, TEXT_DIM, TEXT_FAINT}; +use crate::widgets::{self, Icon}; + +#[derive(Default)] +pub struct Publish { + pub open: bool, + /// the details as typed + form: Form, + /// the session's details the form last showed: when they change elsewhere (an assistant set them), so does it + shown: Option, + /// the details last sent, which come back as the session's + sent: Option, + /// a picture to send as the artwork, dropped or read from `path` + pub upload: Option>, + /// the preview on screen, and the artwork version it shows + preview: Option<(u64, egui::TextureHandle)>, + /// a preview that arrived, to make a texture of + pub arrived: Option<(u64, Result)>, + /// the artwork version whose preview was asked for + asked: Option, + /// a path to read the artwork from: a browser has no files to name + #[cfg(not(target_arch = "wasm32"))] + path: String, + /// why a picture could not be read or shown + error: Option, + /// dropped pictures a browser is reading + reading: Option, + /// the dialog has just opened: the session is told, and checks the release + opened: bool, +} + +type ReadChannel = + (std::sync::mpsc::Sender, String>>, std::sync::mpsc::Receiver, String>>); + +/// the details as the form edits them: lists are names between commas +#[derive(Clone, Default, PartialEq)] +struct Form { + artist: String, + title: String, + version: String, + featuring: String, + album: String, + genre: String, + subgenre: String, + date: String, + label: String, + writers: String, + producers: String, + owner: String, + isrc: String, + upc: String, + explicit: bool, + made: Option, +} + +fn split(s: &str) -> Vec { + s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() +} + +impl Form { + fn of(d: &ReleaseDetails) -> Form { + Form { + artist: d.artist.clone(), + title: d.title.clone(), + version: d.version.clone(), + featuring: d.featuring.join(", "), + album: d.album.clone(), + genre: d.genre.clone(), + subgenre: d.subgenre.clone(), + date: d.date.clone(), + label: d.label.clone(), + writers: d.writers.join(", "), + producers: d.producers.join(", "), + owner: d.owner.clone(), + isrc: d.isrc.clone(), + upc: d.upc.clone(), + explicit: d.explicit, + made: d.made.clone(), + } + } + + fn details(&self) -> ReleaseDetails { + let text = |s: &str| s.trim().to_string(); + ReleaseDetails { + artist: text(&self.artist), + title: text(&self.title), + version: text(&self.version), + featuring: split(&self.featuring), + album: text(&self.album), + genre: text(&self.genre), + subgenre: text(&self.subgenre), + date: text(&self.date), + label: text(&self.label), + writers: split(&self.writers), + producers: split(&self.producers), + owner: text(&self.owner), + isrc: text(&self.isrc), + upc: text(&self.upc), + explicit: self.explicit, + made: self.made.clone(), + } + } +} + +impl Publish { + pub fn show(&mut self) { + self.open = true; + self.opened = true; + self.error = None; + } + + /// the artwork version to fetch a preview of, once each + pub fn wants_preview(&mut self, release: &ReleaseView) -> Option { + let v = release.artwork_version; + let shown = self.preview.as_ref().map(|(pv, _)| *pv); + let wanted = self.open && release.artwork.is_some() && shown != Some(v) && self.asked != Some(v); + wanted.then(|| { + self.asked = Some(v); + v + }) + } +} + +/// a picture dropped on the studio becomes the artwork (a browser reads it while the studio goes on) +fn take_drop(ctx: &egui::Context, p: &mut Publish) { + if let Some((_, rx)) = &p.reading + && let Ok(read) = rx.try_recv() + { + match read { + Ok(bytes) => p.upload = Some(bytes), + Err(e) => p.error = Some(e), + } + } + let Some(file) = ctx.input(|i| i.raw.dropped_files.first().cloned()) else { return }; + p.error = None; + #[cfg(not(target_arch = "wasm32"))] + match file.bytes() { + Ok(bytes) => p.upload = Some(bytes), + Err(e) => p.error = Some(format!("cannot read {}: {e}", file.path().display())), + } + #[cfg(target_arch = "wasm32")] + { + let tx = p.reading.get_or_insert_with(std::sync::mpsc::channel).0.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let read = file.bytes_async().await.map_err(|e| format!("cannot read {}: {e}", file.path().display())); + let _ = tx.send(read); + ctx.request_repaint(); + }); + } +} + +/// the dialog, while it is open +pub fn dialog(ctx: &egui::Context, cx: &mut Cx) { + if !cx.view.publish.open { + return; + } + let release = cx.state.release.clone(); + let song_version = cx.state.song_version; + let p = &mut cx.view.publish; + take_drop(ctx, p); + if let Some((v, image)) = p.arrived.take() { + match image { + Ok(img) => p.preview = Some((v, ctx.load_texture("release-artwork", img, egui::TextureOptions::LINEAR))), + Err(e) => p.error = Some(format!("cannot show the artwork: {e}")), + } + } + if let Some(r) = &release + && p.shown.as_ref() != Some(&r.details) + { + if p.sent.as_ref() != Some(&r.details) { + p.form = Form::of(&r.details); + } + p.shown = Some(r.details.clone()); + } + let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); + let mut out = Vec::new(); + // the session checks a release once something is given for it: opening the dialog is asking for the checks + if std::mem::take(&mut p.opened) + && let Some(r) = &release + { + out.push(Command::SetRelease { details: Box::new(r.details.clone()), artwork: None }); + } + let modal = egui::Modal::new(egui::Id::unique("publish")).show(ctx, |ui| { + ui.set_width(780.0); + ui.heading("Publish"); + ui.add_space(4.0); + ui.label(small( + "Spotify, Apple Music and the other stores take music from distributors. claudius checks the song the \ + way they do, and writes the files and the answers a distributor's form asks for.", + )); + ui.add_space(10.0); + let Some(release) = &release else { + ui.label(RichText::new("Make a song first.").color(TEXT)); + close_row(ui, p); + return; + }; + let mut send = false; + let height = (ctx.content_rect().height() - 220.0).max(240.0); + egui::ScrollArea::vertical().max_height(height).auto_shrink([false, true]).show(ui, |ui| { + ui.horizontal_top(|ui| { + artwork(ui, p, release, hovering); + ui.add_space(16.0); + ui.vertical(|ui| send |= details(ui, &mut p.form, release)); + }); + ui.add_space(12.0); + send |= made(ui, &mut p.form, release); + ui.add_space(12.0); + checks(ui, release, song_version); + }); + if send && p.form.details() != release.details { + let details = p.form.details(); + p.sent = Some(details.clone()); + out.push(Command::SetRelease { details: Box::new(details), artwork: None }); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let current = release.checked > 0 && release.checked >= song_version; + let can = current && release.ready; + let why = if !current { + "the checks come once the song as it is now is rendered" + } else { + "fix the problems first: they are marked red" + }; + let r = widgets::tool_button(ui, Icon::Save, "Write the release kit", can, can) + .on_hover_text("the WAVs, the cover and release.txt, into release/ in the song's folder") + .on_disabled_hover_text(why); + if r.clicked() { + out.push(Command::WriteKit { path: None }); + } + if let Some(k) = &release.kit { + ui.add(egui::Label::new(small(format!("written to {k}"))).truncate()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::tool_button(ui, Icon::Cancel, "Close", true, false).clicked() { + p.open = false; + } + }); + }); + }); + if modal.should_close() { + cx.view.publish.open = false; + } + cx.out.extend(out); +} + +fn close_row(ui: &mut Ui, p: &mut Publish) { + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::tool_button(ui, Icon::Cancel, "Close", true, false).clicked() { + p.open = false; + } + }); +} + +/// the cover, or where to drop it, with what it is +fn artwork(ui: &mut Ui, p: &mut Publish, release: &ReleaseView, hovering: bool) { + ui.vertical(|ui| { + ui.set_width(220.0); + let (rect, _) = ui.allocate_exact_size(vec2(220.0, 220.0), Sense::hover()); + let edge = if hovering { theme::ACCENT } else { theme::LINE }; + ui.painter().rect( + rect, + 6.0, + theme::WELL, + Stroke::new(if hovering { 2.0 } else { 1.0 }, edge), + StrokeKind::Inside, + ); + match &p.preview { + Some((v, tex)) if *v == release.artwork_version && release.artwork.is_some() => { + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + let size = tex.size_vec2(); + let fit = size * (rect.width() / size.x).min(rect.height() / size.y); + ui.painter().image(tex.id(), egui::Rect::from_center_size(rect.center(), fit), uv, Color32::WHITE); + } + _ => { + let text = if hovering { "drop it here" } else { "drop the cover here" }; + let font = egui::FontId::proportional(14.0); + ui.painter().text(rect.center() - vec2(0.0, 9.0), egui::Align2::CENTER_CENTER, text, font, TEXT_DIM); + let note = "a square JPEG or PNG, 3000 × 3000"; + let font = egui::FontId::proportional(11.0); + ui.painter().text(rect.center() + vec2(0.0, 11.0), egui::Align2::CENTER_CENTER, note, font, TEXT_FAINT); + } + } + ui.add_space(4.0); + let what = release.artwork.clone().unwrap_or_else(|| "no artwork yet".into()); + ui.add(egui::Label::new(small(what)).wrap()); + #[cfg(not(target_arch = "wasm32"))] + ui.horizontal(|ui| { + let typed = ui.add(egui::TextEdit::singleline(&mut p.path).hint_text("or its path").desired_width(180.0)); + let entered = typed.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let named = !p.path.trim().is_empty(); + if widgets::icon_button(ui, Icon::Open, 24.0, false, named, "use this file").clicked() || (entered && named) + { + match std::fs::read(p.path.trim()) { + Ok(bytes) => { + p.upload = Some(bytes); + p.error = None; + } + Err(e) => p.error = Some(format!("cannot read {}: {e}", p.path.trim())), + } + } + }); + if let Some(e) = &p.error { + ui.add(egui::Label::new(RichText::new(e).size(11.0).color(theme::BAD)).wrap()); + } + }); +} + +/// the form; true when a field was left (or the checkbox changed), to send what it says +fn details(ui: &mut Ui, form: &mut Form, release: &ReleaseView) -> bool { + let mut left = false; + let artist = if form.artist.trim().is_empty() { "the artist's name".to_string() } else { form.artist.clone() }; + egui::Grid::new("release_details").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + let mut row = |ui: &mut Ui, label: &str, value: &mut String, hint: &str| { + ui.label(RichText::new(label).size(12.0).color(TEXT_DIM)); + left |= ui.add(egui::TextEdit::singleline(value).hint_text(hint).desired_width(330.0)).lost_focus(); + ui.end_row(); + }; + row(ui, "Artist", &mut form.artist, "the name the song comes out under"); + row(ui, "Title", &mut form.title, &release.song_title); + row(ui, "Version", &mut form.version, "Extended Mix, Radio Edit (if it is one)"); + row(ui, "Featuring", &mut form.featuring, "featured artists, between commas"); + row(ui, "Album or EP", &mut form.album, "without one, the song is a single"); + row(ui, "Genre", &mut form.genre, "Dance, Electronic, Hip-Hop/Rap"); + row(ui, "Subgenre", &mut form.subgenre, "House, Techno, Lo-Fi Hip-Hop"); + row(ui, "Release date", &mut form.date, "YYYY-MM-DD, four weeks ahead or more"); + row(ui, "Label", &mut form.label, &artist); + row(ui, "Songwriters", &mut form.writers, "full legal names, between commas"); + row(ui, "Producers", &mut form.producers, "between commas"); + row(ui, "Owner", &mut form.owner, &format!("{artist}, for the copyright lines")); + row(ui, "ISRC", &mut form.isrc, "the distributor assigns one"); + row(ui, "UPC", &mut form.upc, "the distributor assigns one"); + ui.label(RichText::new("Lyrics").size(12.0).color(TEXT_DIM)); + left |= ui.checkbox(&mut form.explicit, small("explicit")).changed(); + ui.end_row(); + }); + left +} + +/// how the song was made, which only the person can say; true when it changed +fn made(ui: &mut Ui, form: &mut Form, release: &ReleaseView) -> bool { + widgets::heading(ui, "How was it made?"); + ui.label(small("Distributors ask whether music is made with AI, and stores label it. Only you can answer.")); + ui.add_space(4.0); + let mut changed = false; + for (name, what) in &release.made_options { + let on = form.made.as_deref() == Some(name.as_str()); + let text = RichText::new(what).size(12.5).color(if on { TEXT } else { TEXT_DIM }); + if ui.radio(on, text).clicked() && !on { + form.made = Some(name.clone()); + changed = true; + } + } + changed +} + +/// what stores and distributors would say, problems first +fn checks(ui: &mut Ui, release: &ReleaseView, song_version: u64) { + widgets::heading(ui, "What stores would say"); + ui.add_space(2.0); + if release.checked == 0 { + ui.label(small("checked once the song is rendered")); + return; + } + if release.checked < song_version { + ui.label(RichText::new("of an earlier render: checked again after the next one").size(11.0).color(theme::WARN)); + } + if release.checks.is_empty() { + ui.label(RichText::new("nothing to fix: ready for a distributor").size(12.5).color(theme::GOOD)); + } + for c in &release.checks { + let color = match c.level.as_str() { + "problem" => theme::BAD, + "warning" => theme::WARN, + _ => TEXT_FAINT, + }; + ui.horizontal_top(|ui| { + let (dot, _) = ui.allocate_exact_size(vec2(12.0, 16.0), Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.5, color); + let text = format!("{}: {}", c.about, c.message); + let tint = if c.level == "note" { TEXT_DIM } else { TEXT }; + ui.add(egui::Label::new(RichText::new(text).size(12.0).color(tint)).wrap()); + }); + } +} diff --git a/crates/claudius-ui/src/panels/transport.rs b/crates/claudius-ui/src/panels/transport.rs index c94b0f8..59994a3 100644 --- a/crates/claudius-ui/src/panels/transport.rs +++ b/crates/claudius-ui/src/panels/transport.rs @@ -316,6 +316,14 @@ pub fn toolbar(ui: &mut Ui, cx: &mut Cx) { .response .on_hover_text("download the song as WAV, AIFF, FLAC, Apple Lossless, MP3, AAC, Opus or Ogg Vorbis"); } + if has_song + && ui + .button(small("publish")) + .on_hover_text("check the song as stores do, and write the files and answers a distributor takes") + .clicked() + { + cx.view.publish.show(); + } ui.separator(); if widgets::tool_button(ui, Icon::Mutate, "Mutate", has_song && !busy, false) .on_hover_text("try random mutations, listen to each, keep the best") diff --git a/crates/claudius/src/publish/mod.rs b/crates/claudius/src/publish/mod.rs index e5ac86d..c8200ab 100644 --- a/crates/claudius/src/publish/mod.rs +++ b/crates/claudius/src/publish/mod.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -pub use artwork::{Artwork, Colour}; +pub use artwork::{Artwork, Colour, inspect}; use crate::audio::Stereo; use crate::export::{self, Spec}; @@ -175,6 +175,28 @@ pub fn save(dir: &Path, r: &Release) -> Result<()> { std::fs::write(&file, serde_json::to_string_pretty(r)?).with_context(|| format!("cannot write {}", file.display())) } +/// The artwork's file: a path in release.json is the song's folder's when it is relative. +pub fn artwork_file(dir: &Path, r: &Release) -> Option { + r.artwork.as_ref().map(|a| if a.is_absolute() { a.clone() } else { dir.join(a) }) +} + +/// Keep artwork that came as bytes (from the studio) in the song's folder, as `artwork.jpg` or `artwork.png`; +/// returns its name there, for `Release::artwork`. +pub fn save_artwork(dir: &Path, bytes: &[u8]) -> Result { + let name = if bytes.starts_with(b"\x89PNG") { "artwork.png" } else { "artwork.jpg" }; + let file = dir.join(name); + std::fs::write(&file, bytes).with_context(|| format!("cannot write {}", file.display()))?; + Ok(PathBuf::from(name)) +} + +/// The artwork at most `side` pixels on a side, as PNG, for showing it; None when it is not a picture that reads. +pub fn preview(bytes: &[u8], side: u32) -> Option> { + let img = image::load_from_memory(bytes).ok()?.thumbnail(side, side).into_rgba8(); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + /// The genre and subgenre stores list a claudius genre under. pub fn store_genre(genre: &str) -> (&'static str, &'static str) { match genre { diff --git a/docs/src/engineering/ui.md b/docs/src/engineering/ui.md index ba2d1cf..0ec18f9 100644 --- a/docs/src/engineering/ui.md +++ b/docs/src/engineering/ui.md @@ -143,7 +143,8 @@ CLAUDIUS_UI_PLAN='wait:4;shot:/tmp/a.png;tab:mixer;play;wait:2;shot:/tmp/b.png;e ``` The steps: `wait:`, `tab:`, `section:`, `play`, `pause`, `seek:`, `newsong`, `close`, -`tour[:]`, `assistants` (the Connect an assistant dialog), `cmd:`, `size:,`, `script:` +`tour[:]`, `assistants` (the Connect an assistant dialog), `publish` (the Publish dialog), `cmd:`, +`size:,`, `script:` (into the editor, `\n` for a newline and `%3B` for `;`), `run`, `reference`, `audiopath:`, `shot:` (the window as rendered), `exit`, and synthetic pointer input in points: `move:x,y`, `click:x,y`, `rclick:x,y`, `drag:x1,y1,x2,y2`, `sdrag:x1,y1,x2,y2` (with shift). A scripted run starts the first-run tour only when a `tour` step diff --git a/docs/src/getting-started/studio.md b/docs/src/getting-started/studio.md index 97745c1..a934331 100644 --- a/docs/src/getting-started/studio.md +++ b/docs/src/getting-started/studio.md @@ -54,7 +54,8 @@ song, so a seed is a way to come back to one you liked. **Open** loads a song fo **Mutate** tries a few variations and keeps the best one if it beats the current song; **Evolve** runs several generations of that. Both run in the background and can be stopped. **Save** writes the song folder, and the **export** menu downloads the song as WAV, AIFF, FLAC, Apple Lossless, MP3, AAC, Opus or Ogg Vorbis -([Exporting](../guide/export.md)). +([Exporting](../guide/export.md)). **publish** checks the song as stores do and writes what a distributor takes +([Publishing](../guide/publish.md)). ## Help and assistants diff --git a/docs/src/guide/http-api.md b/docs/src/guide/http-api.md index bf80deb..9c3f1e8 100644 --- a/docs/src/guide/http-api.md +++ b/docs/src/guide/http-api.md @@ -21,6 +21,9 @@ this API, so anything it does you can do with `curl`. The types are plain serde | `GET /api/audio/{version}.mp3` | the same as MP3, encoded on the first request | | `GET /api/stems/{version}/{track}.wav` | one track of a render made with stems | | `GET /api/export/{version}/{name}.{ext}?format=SPEC` | that render in another [format](export.md#formats), as a download named `{name}.{ext}` | +| `POST /api/release/artwork` | the release's [artwork](publish.md), as the file itself with `Content-Type: image/jpeg`, `image/png` or `application/octet-stream` (40 MB at most); answers with a [reply](#replies-and-errors) | +| `GET /api/release/artwork` | the artwork as it was given | +| `GET /api/release/preview.png` | the artwork at most 512 pixels on a side, as PNG | | `/mcp` | the [MCP endpoint](mcp.md#mcp-over-http) | | `GET /` and anything else | the web studio; when it is not built, a page that says how to build it | @@ -124,6 +127,7 @@ The MCP tools wait the same way after every edit. | `undo_label`, `redo_label` | string or null | what undo would take back, what redo would bring back | | `log` | strings | the last 200 lines of the session log, oldest first | | `genres` | `GenreInfo` list | every genre: `name`, `description`, `bpm` range | +| `release` | `ReleaseView` or null | the release in the making for the song: its `details` (as `set_release` takes them), `song_title`, what the `artwork` is and its `artwork_version`, the `checks` (each a `level`, `problem`, `warning` or `note`, what it is `about` and its `message`) of the render `checked`, whether it is `ready`, where the last `kit` went, and the `made_options` | ### `SongView` @@ -178,6 +182,8 @@ fields marked optional may be left out. | `load` | `path` | opens a song folder, a `blueprint.json`, or a script file (run on the open song) | | `save` | optional `path` | writes `blueprint.json` and `song.rhai` now, then the whole folder from a render (see [Jobs](#jobs)); default folder `~/Music/claudius/--<seed>` | | `export` | `formats` (such as `["mp3", "flac:16"]`), optional `path` | writes the latest render in those [formats](export.md#formats), when it is of the song as it is now (otherwise: `the song has changed since its last render: render it, then export`); default folder as for `save` | +| `set_release` | `details` (every field of `ReleaseDetails`: `artist`, `title`, `writers`, `made` ...), optional `artwork` (a JPEG or PNG file) | replaces what stores are told about the [release](publish.md), and checks it again | +| `write_kit` | optional `path` | writes the release kit from the latest render, when it is of the song as it is now and nothing stops the release; default `release/` in the song's folder | | `set_tempo` | `bpm` | as `tempo()` | | `set_key` | `key` | as `key()`: the progressions follow a change of mode | | `set_swing` | `swing` | as `swing()` | diff --git a/docs/src/guide/mcp.md b/docs/src/guide/mcp.md index e985370..692ee8d 100644 --- a/docs/src/guide/mcp.md +++ b/docs/src/guide/mcp.md @@ -155,6 +155,7 @@ says the job is still running; `status` follows it from there. | `catalog` | | the genres with tempo ranges, drum names, synth presets, installed instruments and every pack with its licence | | `save` | `path` (optional) | writes the song folder: `blueprint.json` and `song.rhai` at once, then, from a render of the song as it is then, the whole folder `claudius make` writes, stems included; a save asked for during a job waits for it | | `export` | `formats` (required, such as `["mp3", "flac:16"]`), `path` (optional) | writes the song in those [formats](export.md#formats) into its folder or `path`, named after the song; renders first when the song changed since its last render | +| `publish` | what the person said: `artist`, `title`, `version`, `featuring`, `album`, `genre`, `subgenre`, `date`, `label`, `writers`, `producers`, `owner`, `isrc`, `upc`, `explicit`, `made`, `artwork` (a file); `write`, `path` | checks the song for release as stores do and, with `write`, writes the [release kit](publish.md); what is left out stays as it was, and the assistant is told never to choose `made` | | `open` | `path` (required) | opens a song folder, a `blueprint.json`, or a `.rhai` script (which runs on the open song) | | `analyze` | `path` (required) | tempo, key, progressions, sections, drum grid and bass style of an audio file | | `remake` | `path` (required), `genre`, `seed`, `bpm` | a new song on the skeleton of an audio file, in a genre's sounds, at a tempo the genre plays | diff --git a/docs/src/guide/publish.md b/docs/src/guide/publish.md index f4727ee..29fff2e 100644 --- a/docs/src/guide/publish.md +++ b/docs/src/guide/publish.md @@ -33,6 +33,29 @@ Checks marked `--` are problems: a store would turn the release down, so no kit the command fails. `!` marks a warning, worth fixing, and `..` a note, worth knowing. `--json` prints the checks as JSON. +## In the studio + +**publish** in the toolbar opens the Publish dialog. Drop the cover onto the studio (the native studio also takes its +path), fill in what stores ask, and say how the song was made; each field is sent as you leave it, and the checks +below, of the latest render, come back with the answer. **Write the release kit** writes it into `release/` in the +song's folder once nothing stops the release. The details and the artwork stay with the song: saving it, or writing +the kit, puts `release.json` and `artwork.jpg` (or `artwork.png`) in its folder, and opening that folder again brings +them back. In the web studio, the browser reads the dropped cover and sends it to claudius. + +## Over MCP and HTTP + +An assistant has the `publish` tool. It passes on what the person said (the artist, the songwriters, the artwork's +file, how the song was made) and gets the checks back, and with `write`, it writes the kit too: + +```json +{"artist": "Ana Lima", "writers": ["Ana Lima"], "artwork": "/Users/ana/Pictures/hollow-harbor.jpg", "made": "assisted", "write": true} +``` + +What the tool leaves out stays as it was. Its description, and the claudius-songs skill, tell the assistant to ask +the person for these, and never to answer how the song was made for them. Over the [HTTP API](http-api.md), +`set_release` sets the details and an artwork file, `POST /api/release/artwork` takes the artwork as the file itself, +`write_kit` writes the kit, and `State.release` holds the checks. + ## The release kit The kit goes into `release/` in the song's folder, or the folder `--out` names. Writing it again replaces it, and diff --git a/skills/claudius-songs/SKILL.md b/skills/claudius-songs/SKILL.md index 922a335..5c17923 100644 --- a/skills/claudius-songs/SKILL.md +++ b/skills/claudius-songs/SKILL.md @@ -140,10 +140,10 @@ writes the song in other formats, named after it: `wav:16` for a CD or a distrib Paths always mean the computer running claudius, and a claudius shared over a network only reads and writes its own folders. -To release a song on Spotify, Apple Music and the other stores, which take music only from distributors, -`claudius publish SONG` checks a saved song as they do and writes a release kit: the WAV and the cover a distributor -takes, and `release.txt` with the answers its form asks for, the credits CC BY packs need among them. Before you run -it: +To release a song on Spotify, Apple Music and the other stores, which take music only from distributors, `publish` +(or `claudius publish SONG`) checks the song as they do, and with `write`, writes a release kit: the WAV and the cover +a distributor takes, and `release.txt` with the answers its form asks for, the credits CC BY packs need among them. +The person can do the same in the studio's Publish dialog. Before you call it: - Ask the person for the artist name, the songwriters' full legal names and the artwork (a square JPEG or PNG, 3000 × 3000). Never make any of them up. diff --git a/skills/claudius-songs/references/tools.md b/skills/claudius-songs/references/tools.md index 6d280e6..43d34c2 100644 --- a/skills/claudius-songs/references/tools.md +++ b/skills/claudius-songs/references/tools.md @@ -17,7 +17,7 @@ to. On the command line, each command reads and writes song folders instead. | `catalog` | | `claudius genres`; `claudius-packs instruments` (installed next to `claudius`) | | `save` | `path` (default `~/Music/claudius/<genre>-<title>-<seed>`) | every command writes a song folder | | `export` | `formats` (`["mp3", "flac:16", "opus"]`), `path` | `claudius export SONG -f mp3,flac:16,opus` | -| | | `claudius publish SONG --artwork FILE --artist NAME --writer "FULL NAME" --made written` checks a saved song for release and writes a release kit; `--check` only checks | +| `publish` | what the person said: `artist`, `writers`, `artwork` (a file), `made`, and `title`, `album`, `genre`, `date` ...; `write` (default false: only check), `path` | `claudius publish SONG --artwork FILE --artist NAME --writer "FULL NAME" --made written` (`--check` only checks) | | `open` | `path`: a song folder, a `blueprint.json` or a `.rhai` script | `--on SONG` | | `analyze` | `path` of any audio file | `claudius analyze song.mp3` | | `remake` | `path`, `genre`, `seed`, `bpm` | `claudius remake song.mp3 --genre lofi [--bpm 82]` |