diff --git a/.github/workflows/build-workspace.yml b/.github/workflows/build-workspace.yml index d2d097e..8cf7bfb 100644 --- a/.github/workflows/build-workspace.yml +++ b/.github/workflows/build-workspace.yml @@ -43,12 +43,18 @@ jobs: name: ntsc-rs-linux-openfx path: crates/openfx-plugin/build/ + - name: Package Linux binaries + run: | + mkdir ntsc-rs-linux-standalone + cp target/release/ntsc-rs-standalone ntsc-rs-linux-standalone + cp target/release/ntsc-rs-cli ntsc-rs-linux-standalone + - name: Archive Linux binary uses: actions/upload-artifact@v4 if: ${{ github.ref_type == 'tag' }} with: name: ntsc-rs-linux-standalone - path: target/release/ntsc-rs-standalone + path: ntsc-rs-standalone build-windows: runs-on: windows-2019 @@ -131,6 +137,7 @@ jobs: robocopy $Env:GSTREAMER_1_0_ROOT_MSVC_X86_64 .\ *.dll /s /copy:DT; if ($lastexitcode -lt 8) { $global:LASTEXITCODE = $null } robocopy $Env:GSTREAMER_1_0_ROOT_MSVC_X86_64\share\licenses .\licenses /s /copy:DT; if ($lastexitcode -lt 8) { $global:LASTEXITCODE = $null } cp ..\target\release\ntsc-rs-standalone.exe .\bin\ + cp ..\target\release\ntsc-rs-cli.exe .\bin\ cp ..\target\release\ntsc-rs-launcher.exe .\ - name: Archive Windows binary diff --git a/Cargo.lock b/Cargo.lock index 94886c8..be365af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "addr2line" -version = "0.24.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -672,17 +672,17 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", + "cc", "cfg-if", "libc", - "miniz_oxide 0.8.0", + "miniz_oxide 0.7.4", "object", "rustc-demangle", - "windows-targets 0.52.6", ] [[package]] @@ -1073,6 +1073,33 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.2" @@ -1129,6 +1156,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "const_format" version = "0.2.33" @@ -1562,6 +1602,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "endi" version = "1.1.0" @@ -1707,6 +1753,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -1958,9 +2014,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gio-sys" @@ -2351,6 +2407,9 @@ dependencies = [ "arboard", "async-executor", "blocking", + "clap", + "color-eyre", + "console", "eframe", "embed-resource", "env_logger", @@ -2514,6 +2573,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "2.5.0" @@ -3300,9 +3365,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -3388,6 +3453,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "pango-sys" version = "0.18.0" @@ -4098,6 +4169,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4401,6 +4481,16 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tiff" version = "0.9.1" @@ -4582,6 +4672,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] @@ -4702,6 +4814,12 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "version-compare" version = "0.2.0" diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index 2fdf500..56fc6a3 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -26,6 +26,9 @@ open = "5.1.4" serde = "1.0" trash = "5.0.0" blocking = "1.6.1" +clap = { version = "4.5.17", features = ["cargo"] } +color-eyre = "0.6.3" +console = "0.15.8" [build-dependencies] embed-resource = "2.4" @@ -36,5 +39,8 @@ name = "ntsc-rs-standalone" [[bin]] name = "ntsc-rs-launcher" +[[bin]] +name = "ntsc-rs-cli" + [lints] workspace = true diff --git a/crates/gui/src/app/executor.rs b/crates/gui/src/app/executor.rs index 5f7b4aa..2e6f5b7 100644 --- a/crates/gui/src/app/executor.rs +++ b/crates/gui/src/app/executor.rs @@ -10,7 +10,7 @@ use futures_lite::{Future, FutureExt}; use gstreamer::glib::clone::Downgrade; use log::trace; -use super::AppFn; +use super::{AppFn, ApplessFn, NtscApp}; struct AppExecutorInner { executor: Arc>, @@ -118,3 +118,19 @@ impl AppTaskSpawner { exec.lock().unwrap().spawn(future); } } + +pub trait ApplessExecutor: Send + Sync { + fn spawn(&self, future: impl Future> + 'static + Send); +} + +impl ApplessExecutor for AppExecutor { + fn spawn(&self, future: impl Future> + 'static + Send) { + self.spawn(async { future.await.map(|cb| Box::new(|_: &mut NtscApp| cb()) as _) }); + } +} + +impl ApplessExecutor for AppTaskSpawner { + fn spawn(&self, future: impl Future> + 'static + Send) { + self.spawn(async { future.await.map(|cb| Box::new(|_: &mut NtscApp| cb()) as _) }); + } +} diff --git a/crates/gui/src/app/main.rs b/crates/gui/src/app/main.rs index dfefe87..8ff9b06 100644 --- a/crates/gui/src/app/main.rs +++ b/crates/gui/src/app/main.rs @@ -25,6 +25,7 @@ use crate::{ egui_sink::{EffectPreviewSetting, EguiCtx, EguiSink, SinkTexture}, elements, gstreamer_error::GstreamerError, + init::initialize_gstreamer, ntsc_pipeline::{NtscPipeline, PipelineError, VideoElemMetadata, VideoScaleFilter}, ntscrs_filter::NtscFilterSettings, }, @@ -37,7 +38,7 @@ use crate::{ use ntscrs::settings::{ easy::{self, EasyModeFullSettings}, - standard::{setting_id, NtscEffectFullSettings, UseField}, + standard::{setting_id, NtscEffectFullSettings}, SettingDescriptor, SettingKind, Settings, SettingsList, }; use snafu::ResultExt; @@ -65,44 +66,6 @@ use super::{ const EXPERIMENTAL_EASY_MODE: bool = false; -fn initialize_gstreamer() -> Result<(), GstreamerError> { - gstreamer::init()?; - - gstreamer::Element::register( - None, - "eguisink", - gstreamer::Rank::NONE, - elements::EguiSink::static_type(), - )?; - - gstreamer::Element::register( - None, - "ntscfilter", - gstreamer::Rank::NONE, - elements::NtscFilter::static_type(), - )?; - - gstreamer::Element::register( - None, - "videopadfilter", - gstreamer::Rank::NONE, - elements::VideoPadFilter::static_type(), - )?; - - // PulseAudio has a severe bug that will greatly delay initial playback to the point of unusability: - // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/1383 - // A fix was merged a *year* ago, but the Pulse devs, in their infinite wisdom, won't give it to us until their - // next major release, the first RC of which will apparently arrive "soon": - // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/3757#note_2038416 - // Until then, disable it and pray that someone writes a PipeWire sink so we don't have to deal with any more - // bugs like this - if let Some(sink) = gstreamer::ElementFactory::find("pulsesink") { - sink.set_rank(gstreamer::Rank::NONE); - } - - Ok(()) -} - fn format_percentage(n: f64, prec: RangeInclusive) -> String { format!("{:.*}%", prec.start().max(&2) - 2, n * 100.0) } @@ -484,13 +447,6 @@ impl NtscApp { }) } - fn interlaced_output_allowed(&self) -> bool { - matches!( - self.effect_settings.use_field, - UseField::InterleavedUpper | UseField::InterleavedLower - ) - } - fn create_render_job( &mut self, ctx: &egui::Context, @@ -518,7 +474,7 @@ impl NtscApp { }; RenderJob::create( - self.executor.make_spawner(), + &self.executor.make_spawner(), ctx, src_path, settings, @@ -1181,7 +1137,7 @@ impl NtscApp { match self.render_settings.output_codec { OutputCodec::H264 => { ui.add( - egui::Slider::new(&mut self.render_settings.h264_settings.crf, 0..=50) + egui::Slider::new(&mut self.render_settings.h264_settings.quality, 0..=50) .text("Quality"), ).on_hover_text("Video quality factor, where 0 is the worst quality and 50 is the best. Higher quality videos take up more space."); ui.add( @@ -1317,7 +1273,7 @@ impl NtscApp { ui .add_enabled( - self.interlaced_output_allowed(), + self.effect_settings.use_field.interlaced_output_allowed(), egui::Checkbox::new(&mut self.render_settings.interlaced, "Interlaced output") ) .on_disabled_hover_text("To enable interlaced output, set the \"Use field\" setting to \"Interleaved\"."); @@ -1333,19 +1289,7 @@ impl NtscApp { let render_job = self.create_render_job( ui.ctx(), &src_path.unwrap().clone(), - RenderPipelineSettings { - codec_settings: (&self.render_settings).into(), - output_path: self.render_settings.output_path.clone(), - interlacing: match ( - self.interlaced_output_allowed() && self.render_settings.interlaced, - self.effect_settings.use_field - ) { - (true, UseField::InterleavedUpper) => RenderInterlaceMode::TopFieldFirst, - (true, UseField::InterleavedLower) => RenderInterlaceMode::BottomFieldFirst, - _ => RenderInterlaceMode::Progressive, - }, - effect_settings: (&self.effect_settings).into(), - }, + RenderPipelineSettings::from_gui_settings(&self.effect_settings, &self.render_settings), ); match render_job { Ok(render_job) => { diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index cd0c034..ec636db 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -20,8 +20,10 @@ pub mod presets; pub mod render_job; pub mod render_settings; pub mod third_party_licenses_dialog; +pub mod ui_context; pub type AppFn = Box Result<(), error::ApplicationError> + Send>; +pub type ApplessFn = Box Result<(), error::ApplicationError> + Send>; pub struct NtscApp { pub gstreamer_init: GstreamerInitState, diff --git a/crates/gui/src/app/render_job.rs b/crates/gui/src/app/render_job.rs index 207e5b3..17d3538 100644 --- a/crates/gui/src/app/render_job.rs +++ b/crates/gui/src/app/render_job.rs @@ -1,11 +1,13 @@ use std::{ collections::VecDeque, + future::Future, path::Path, - sync::{Arc, Mutex, OnceLock}, + sync::{Arc, Mutex, MutexGuard, OnceLock}, + task::{Poll, Waker}, }; -use eframe::egui; -use gstreamer::prelude::*; +use futures_lite::FutureExt; +use gstreamer::{prelude::*, ClockTime}; use gstreamer_video::{VideoFormat, VideoInterlaceMode}; use log::debug; use snafu::ResultExt; @@ -21,14 +23,21 @@ use crate::{ use super::{ error::{ApplicationError, CreatePipelineSnafu, CreateRenderJobSnafu}, - executor::AppTaskSpawner, + executor::ApplessExecutor, render_settings::{ Ffv1BitDepth, H264Settings, PngSettings, RenderInterlaceMode, RenderPipelineCodec, StillImageSettings, }, - NtscApp, + ui_context::UIContext, }; +#[derive(Debug, Clone, Copy)] +pub struct RenderJobProgress { + pub progress: f64, + pub position: Option, + pub duration: Option, +} + #[derive(Debug, Clone)] pub enum RenderJobState { Waiting, @@ -49,6 +58,7 @@ pub struct RenderJob { pub start_time: Option, pause_time: Option, pub estimated_time_remaining: Option, + waker: Arc>>, } impl RenderJob { @@ -56,6 +66,7 @@ impl RenderJob { settings: RenderPipelineSettings, pipeline: NtscPipeline, state: Arc>, + waker: Arc>>, ) -> Self { Self { settings, @@ -66,12 +77,13 @@ impl RenderJob { start_time: None, pause_time: None, estimated_time_remaining: None, + waker, } } pub fn create( - executor: AppTaskSpawner, - ctx: &egui::Context, + executor: &(impl ApplessExecutor + Clone + 'static), + ctx: &(impl UIContext + 'static), src_path: &Path, settings: RenderPipelineSettings, still_image_settings: &StillImageSettings, @@ -136,6 +148,8 @@ impl RenderJob { let exec = executor.clone(); let exec_for_handler = executor.clone(); let ctx_for_handler = ctx.clone(); + let waker = Arc::new(Mutex::new(None::)); + let waker_for_handler = waker.clone(); let is_png = matches!(settings.codec_settings, RenderPipelineCodec::Png(_)); @@ -190,7 +204,7 @@ impl RenderJob { // CRF mode .property("pass", GstX264EncPass.to_value_by_nick("quant").unwrap()) // invert CRF (so that low numbers = low quality) - .property("quantizer", 50 - h264_settings.crf as u32) + .property("quantizer", 50 - h264_settings.quality as u32) .property( "speed-preset", GstX264EncPreset @@ -332,6 +346,7 @@ impl RenderJob { let job_state = &job_state_for_handler; let exec = &exec; let ctx = &ctx_for_handler; + let waker = &waker_for_handler; let handle_msg = move |_bus, msg: &gstreamer::Message| -> Option<()> { debug!("{:?}", msg); @@ -342,6 +357,9 @@ impl RenderJob { if !matches!(*job_state, RenderJobState::Error(_)) { *job_state = RenderJobState::Error(Arc::new(err.error().into())); ctx.request_repaint(); + if let Some(waker) = &*waker.lock().unwrap() { + waker.wake_by_ref(); + } } } @@ -350,7 +368,7 @@ impl RenderJob { let pipeline_for_handler = pipeline.clone(); if let gstreamer::MessageView::Eos(_) = msg.view() { let job_state_inner = Arc::clone(job_state); - let end_time = ctx.input(|input| input.time); + let end_time = ctx.current_time(); exec.spawn(async move { let _ = pipeline_for_handler.set_state(gstreamer::State::Null); *job_state_inner.lock().unwrap() = @@ -361,7 +379,7 @@ impl RenderJob { if let gstreamer::MessageView::StateChanged(state_changed) = msg.view() { if state_changed.pending() == gstreamer::State::Null { - let end_time = ctx.input(|input| input.time); + let end_time = ctx.current_time(); *job_state.lock().unwrap() = RenderJobState::Complete { end_time }; } else { *job_state.lock().unwrap() = match state_changed.current() { @@ -369,7 +387,7 @@ impl RenderJob { gstreamer::State::Playing => RenderJobState::Rendering, gstreamer::State::Ready => RenderJobState::Waiting, gstreamer::State::Null => { - let end_time = ctx.input(|input| input.time); + let end_time = ctx.current_time(); RenderJobState::Complete { end_time } } gstreamer::State::VoidPending => { @@ -378,6 +396,9 @@ impl RenderJob { }; } ctx.request_repaint(); + if let Some(waker) = &*waker.lock().unwrap() { + waker.wake_by_ref(); + } } } @@ -397,28 +418,25 @@ impl RenderJob { still_image_settings.framerate, Some(move |p: Result| { exec_for_handler.spawn(async move { - Some( - Box::new(move |_: &mut NtscApp| -> Result<(), ApplicationError> { - let pipeline = p.context(CreatePipelineSnafu)?; - - if let Some(seek_to) = seek_to { - pipeline - .seek_simple( - gstreamer::SeekFlags::FLUSH - | gstreamer::SeekFlags::ACCURATE, - seek_to, - ) - .map_err(|e| e.into()) - .context(CreateRenderJobSnafu)?; - } + Some(Box::new(move || -> Result<(), ApplicationError> { + let pipeline = p.context(CreatePipelineSnafu)?; + if let Some(seek_to) = seek_to { pipeline - .set_state(gstreamer::State::Playing) + .seek_simple( + gstreamer::SeekFlags::FLUSH | gstreamer::SeekFlags::ACCURATE, + seek_to, + ) .map_err(|e| e.into()) .context(CreateRenderJobSnafu)?; - Ok(()) - }) as _, - ) + } + + pipeline + .set_state(gstreamer::State::Playing) + .map_err(|e| e.into()) + .context(CreateRenderJobSnafu)?; + Ok(()) + }) as _) }); }), )?; @@ -429,6 +447,7 @@ impl RenderJob { settings.as_ref().clone(), pipeline, job_state, + waker, )) } @@ -454,6 +473,45 @@ impl RenderJob { } } + pub fn update_progress(&mut self, ctx: &(impl UIContext + 'static)) -> RenderJobProgress { + let job_state = self.state.lock().unwrap().clone(); + + let (progress, job_position, job_duration) = match job_state { + RenderJobState::Waiting => (0.0, None, None), + RenderJobState::Paused | RenderJobState::Rendering | RenderJobState::Error(_) => { + let job_position = self.pipeline.query_position::(); + let job_duration = self.pipeline.query_duration::(); + + ( + if let (Some(job_position), Some(job_duration)) = (job_position, job_duration) { + job_position.nseconds() as f64 / job_duration.nseconds() as f64 + } else { + self.last_progress + }, + job_position, + job_duration, + ) + } + RenderJobState::Complete { .. } => (1.0, None, None), + }; + + if matches!( + job_state, + RenderJobState::Rendering | RenderJobState::Waiting + ) { + let current_time = ctx.current_time(); + self.update_estimated_time_remaining(progress, current_time); + } + + self.last_progress = progress; + + RenderJobProgress { + progress, + position: job_position, + duration: job_duration, + } + } + pub fn update_estimated_time_remaining(&mut self, progress: f64, current_time: f64) { const NUM_PROGRESS_SAMPLES: usize = 5; const PROGRESS_SAMPLE_TIME_DELTA: f64 = 1.0; @@ -504,3 +562,50 @@ impl Drop for RenderJob { let _ = self.pipeline.set_state(gstreamer::State::Null); } } + +impl Future for RenderJob { + type Output = RenderJobState; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + *self.waker.lock().unwrap() = Some(cx.waker().clone()); + + let state = self.state.lock().unwrap(); + match &*state { + RenderJobState::Waiting | RenderJobState::Rendering | RenderJobState::Paused => { + Poll::Pending + } + RenderJobState::Complete { .. } | RenderJobState::Error(..) => { + Poll::Ready(state.clone()) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct SharedRenderJob(Arc>); + +impl SharedRenderJob { + pub fn new(render_job: RenderJob) -> Self { + Self(Arc::new(Mutex::new(render_job))) + } + + pub fn lock(&self) -> MutexGuard<'_, RenderJob> { + self.0.lock().unwrap() + } +} + +impl Future for SharedRenderJob { + type Output = RenderJobState; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.as_ref(); + let mut this = this.0.lock().unwrap(); + this.poll(cx) + } +} diff --git a/crates/gui/src/app/render_settings.rs b/crates/gui/src/app/render_settings.rs index ebeb513..131e996 100644 --- a/crates/gui/src/app/render_settings.rs +++ b/crates/gui/src/app/render_settings.rs @@ -1,25 +1,26 @@ use std::path::PathBuf; use gstreamer::{ClockTime, Fraction}; -use ntscrs::ntsc::NtscEffect; +use ntscrs::ntsc::{NtscEffect, NtscEffectFullSettings, UseField}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct H264Settings { - // Quality / constant rate factor (0-51) - pub crf: u8, - // 0-8 for libx264 presets veryslow-ultrafast + /// Quality (the inverse of the constant rate factor). Although libx264 says it ranges from 0-51, its actual range + /// appears to be 0-50 inclusive. It's flipped from CRF so 50 is lossless and 0 is worst. + pub quality: u8, + /// 0-8 for libx264 presets veryslow-ultrafast pub encode_speed: u8, - // Enable 10-bit color + /// Enable 10-bit color pub ten_bit: bool, - // Subsample chroma to 4:2:0 + /// Subsample chroma to 4:2:0 pub chroma_subsampling: bool, } impl Default for H264Settings { fn default() -> Self { Self { - crf: 23, + quality: 27, encode_speed: 5, ten_bit: false, chroma_subsampling: true, @@ -27,7 +28,7 @@ impl Default for H264Settings { } } -#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Ffv1BitDepth { #[default] Bits8, @@ -57,7 +58,7 @@ pub struct PngSettings { pub seek_to: ClockTime, } -#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OutputCodec { #[default] H264, @@ -87,13 +88,27 @@ pub enum RenderPipelineCodec { Png(PngSettings), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RenderInterlaceMode { + #[default] Progressive, TopFieldFirst, BottomFieldFirst, } +impl RenderInterlaceMode { + pub fn from_use_field(use_field: UseField, enable_interlacing: bool) -> Self { + match ( + use_field.interlaced_output_allowed() && enable_interlacing, + use_field, + ) { + (true, UseField::InterleavedUpper) => RenderInterlaceMode::TopFieldFirst, + (true, UseField::InterleavedLower) => RenderInterlaceMode::BottomFieldFirst, + _ => RenderInterlaceMode::Progressive, + } + } +} + #[derive(Debug, Clone)] pub struct StillImageSettings { pub framerate: Fraction, @@ -108,6 +123,23 @@ pub struct RenderPipelineSettings { pub effect_settings: NtscEffect, } +impl RenderPipelineSettings { + pub fn from_gui_settings( + effect_settings: &NtscEffectFullSettings, + render_settings: &RenderSettings, + ) -> Self { + Self { + codec_settings: render_settings.into(), + output_path: render_settings.output_path.clone(), + interlacing: RenderInterlaceMode::from_use_field( + effect_settings.use_field, + render_settings.interlaced, + ), + effect_settings: effect_settings.into(), + } + } +} + #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct RenderSettings { pub output_codec: OutputCodec, diff --git a/crates/gui/src/app/ui_context.rs b/crates/gui/src/app/ui_context.rs new file mode 100644 index 0000000..2502abf --- /dev/null +++ b/crates/gui/src/app/ui_context.rs @@ -0,0 +1,16 @@ +use eframe::egui; + +pub trait UIContext: Clone + Send + Sync { + fn request_repaint(&self); + fn current_time(&self) -> f64; +} + +impl UIContext for egui::Context { + fn request_repaint(&self) { + self.request_repaint(); + } + + fn current_time(&self) -> f64 { + self.input(|input| input.time) + } +} diff --git a/crates/gui/src/bin/ntsc-rs-cli.rs b/crates/gui/src/bin/ntsc-rs-cli.rs new file mode 100644 index 0000000..bbedede --- /dev/null +++ b/crates/gui/src/bin/ntsc-rs-cli.rs @@ -0,0 +1,530 @@ +use std::{ + fs, + io::{self, Write}, + path::PathBuf, + sync::{ + mpsc::{RecvTimeoutError, Sender}, + Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use clap::{ + builder::{EnumValueParser, PathBufValueParser, PossibleValue}, + command, Arg, ArgGroup, ValueEnum, +}; +use color_eyre::eyre::{Report, Result, WrapErr}; +use console::{style, StyledObject, Term}; +use gstreamer::ClockTime; +use gui::{ + app::{ + executor::ApplessExecutor, + render_job::{RenderJob, RenderJobState, SharedRenderJob}, + render_settings::{ + Ffv1BitDepth, Ffv1Settings, H264Settings, OutputCodec, RenderInterlaceMode, + RenderPipelineCodec, RenderPipelineSettings, StillImageSettings, + }, + ui_context::UIContext, + }, + gst_utils::{ + clock_format::clock_time_parser, + init::initialize_gstreamer, + ntsc_pipeline::{VideoScale, VideoScaleFilter}, + }, +}; +use ntscrs::{ + ntsc::NtscEffectFullSettings, + settings::{ParseSettingsError, Settings, SettingsList}, +}; + +fn parse_settings( + settings_list: &SettingsList, + json: &str, +) -> Result { + settings_list.from_json(json) +} + +#[derive(Clone, Copy, Debug)] +struct OutputCodecArg(OutputCodec); + +impl ValueEnum for OutputCodecArg { + fn value_variants<'a>() -> &'a [Self] { + &[Self(OutputCodec::H264), Self(OutputCodec::Ffv1)] + } + + fn to_possible_value(&self) -> Option { + Some(match self.0 { + OutputCodec::H264 => PossibleValue::new("h264"), + OutputCodec::Ffv1 => PossibleValue::new("ffv1"), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct VideoScaleFilterArg(VideoScaleFilter); + +impl ValueEnum for VideoScaleFilterArg { + fn value_variants<'a>() -> &'a [Self] { + &[ + Self(VideoScaleFilter::Nearest), + Self(VideoScaleFilter::Bilinear), + Self(VideoScaleFilter::Bicubic), + ] + } + + fn to_possible_value(&self) -> Option { + Some( + match self.0 { + VideoScaleFilter::Nearest => PossibleValue::new("nearest"), + VideoScaleFilter::Bilinear => PossibleValue::new("bilinear"), + VideoScaleFilter::Bicubic => PossibleValue::new("bicubic"), + } + .help(self.0.label_and_tooltip().1), + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct Ffv1BitDepthArg(Ffv1BitDepth); + +impl ValueEnum for Ffv1BitDepthArg { + fn value_variants<'a>() -> &'a [Self] { + &[ + Self(Ffv1BitDepth::Bits8), + Self(Ffv1BitDepth::Bits10), + Self(Ffv1BitDepth::Bits12), + ] + } + + fn to_possible_value(&self) -> Option { + Some(match self.0 { + Ffv1BitDepth::Bits8 => PossibleValue::new("8"), + Ffv1BitDepth::Bits10 => PossibleValue::new("10"), + Ffv1BitDepth::Bits12 => PossibleValue::new("12"), + }) + } +} + +fn clock_time_parser_clap(input: &str) -> Result { + match clock_time_parser(input) { + Some(ms) => Ok(gstreamer::ClockTime::from_mseconds(ms as u64)), + None => Err(Report::msg(format!("Not a valid time: {}", input))), + } +} + +pub fn main() -> Result<()> { + color_eyre::install()?; + let settings_list = SettingsList::::new(); + let settings_list_for_parser = settings_list.clone(); + + let parse_standard_settings = move |json: &str| parse_settings(&settings_list_for_parser, json); + + let command = command!() + .name("ntsc-rs") + .arg( + Arg::new("input") + .short('i') + .long("input") + .value_parser(PathBufValueParser::new()) + .help("Path to the input media") + .required(true), + ) + .arg( + Arg::new("output") + .short('o') + .long("output") + .value_parser(PathBufValueParser::new()) + .help("Name/path of the file to render to") + .required(true), + ) + .arg( + Arg::new("settings-path") + .short('p') + .long("settings-path") + .value_parser(PathBufValueParser::new()) + .help("Path to a JSON effect settings preset") + .conflicts_with("settings-json"), + ) + .arg( + Arg::new("settings-json") + .short('j') + .long("settings-json") + // TODO: ValueParser that wraps ntscrs::settings + .help("JSON string for an effect settings preset") + .conflicts_with("settings-path") + .value_parser(parse_standard_settings.clone()), + ) + .group( + ArgGroup::new("settings") + .args(["settings-path", "settings-json"]), + ) + .arg( + Arg::new("fps") + .long("fps") + .help("Framerate to use if the input is a still image") + .value_parser(clap::value_parser!(u32)) + .default_value("30"), + ) + .arg( + Arg::new("duration") + .long("duration") + .help("Duration to use if the input is a still image") + .value_parser(clock_time_parser_clap) + .default_value("5"), + ) + .arg( + Arg::new("scale") + .long("scale") + .help("Height (in lines) to resize the input media to before applying the effect") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("scale-filter") + .long("scale-filter") + .help("Filter to use if resizing the input media") + .value_parser(EnumValueParser::::new()) + .default_value("bilinear"), + ) + .arg( + Arg::new("interlace") + .long("interlace") + .help("Interlace progressive (non-interlaced) input media. Has no effect on media that's already interlaced.") + .action(clap::ArgAction::SetTrue), + ) + .next_help_heading("Codec settings") + .arg( + Arg::new("codec") + .short('c') + .long("codec") + .help("Which video codec to encode the output with") + .value_parser(EnumValueParser::::new()) + .default_value("h264"), + ) + .arg( + Arg::new("chroma-subsampling") + .long("chroma-subsampling") + .help("Encode the chrominance (color) information at a lower resolution than the luminance (brightness). Maximizes playback compatibility for H.264 videos when enabled. Also works with FFV1.") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("quality") + .long("quality") + .help("Video quality factor for H.264 encoding. Ranges from 0-50, where 50 is the best quality and 0 is the worst.") + .value_parser(clap::value_parser!(u8).range(0..=50)) + .default_value("27"), + ) + .arg( + Arg::new("encoding-speed") + .long("encoding-speed") + .help("Encoding speed for H.264 encoding. Ranges from 0-8, where 8 is fastest and 0 is smallest.") + .value_parser(clap::value_parser!(u8).range(0..=8)) + .default_value("5"), + ) + .arg( + Arg::new("bit-depth") + .long("bit-depth") + .help("Bit depth for FFV1 encoding.") + .value_parser(EnumValueParser::::new()) + .default_value("8"), + ); + + let matches = command.get_matches(); + + let settings = if let Some(settings_path) = matches.get_one::("settings-path") { + parse_standard_settings( + std::str::from_utf8(&fs::read(settings_path).wrap_err("Failed to open settings file")?) + .wrap_err("Settings file is not valid UTF-8")?, + ) + .wrap_err("Failed to parse settings file")? + } else if let Some(settings) = matches.get_one::("settings-json") { + settings.clone() + } else { + Default::default() + }; + + let input_path = matches + .get_one::("input") + .expect("input path is present"); + let output_path = matches + .get_one::("output") + .expect("output path is present") + .to_owned(); + let framerate = matches + .get_one::("fps") + .expect("framerate is present") + .to_owned(); + let duration = matches + .get_one::("duration") + .expect("duration is present") + .to_owned(); + let filter = matches + .get_one::("scale-filter") + .expect("scale-filter is present") + .0; + let scale = matches.get_one::("scale").map(|scale| VideoScale { + scanlines: *scale as usize, + filter, + }); + let interlace = matches.get_flag("interlace"); + let codec = matches + .get_one::("codec") + .expect("codec is present") + .0; + let chroma_subsampling = matches.get_flag("chroma-subsampling"); + let quality = matches + .get_one::("quality") + .expect("quality is present") + .to_owned(); + let encode_speed = matches + .get_one::("encoding-speed") + .expect("encoding speed is present") + .to_owned(); + let bit_depth = matches + .get_one::("bit-depth") + .expect("bit depth is present") + .0; + + let mut term = Term::buffered_stdout(); + + const VERSION: &str = env!("CARGO_PKG_VERSION"); + writeln!( + term, + "{}{}{}{}{}{}{} v{VERSION}", + style("n"), + style("t").yellow(), + style("s").cyan(), + style("c").green(), + style("-").magenta(), + style("r").red(), + style("s").blue() + )?; + term.flush()?; + + initialize_gstreamer()?; + + let executor = CliExecutor(Arc::new(async_executor::Executor::new())); + + let (output, handle) = CliOutput::new(term.clone()); + + let render_job = RenderJob::create( + &executor, + &output.clone(), + input_path, + RenderPipelineSettings { + codec_settings: match codec { + OutputCodec::H264 => RenderPipelineCodec::H264(H264Settings { + quality, + encode_speed, + ten_bit: false, + chroma_subsampling, + }), + OutputCodec::Ffv1 => RenderPipelineCodec::Ffv1(Ffv1Settings { + bit_depth, + chroma_subsampling, + }), + }, + output_path, + interlacing: RenderInterlaceMode::from_use_field(settings.use_field, interlace), + effect_settings: settings.into(), + }, + &StillImageSettings { + framerate: gstreamer::Fraction::from_integer(framerate as i32), + duration, + }, + scale, + )?; + let render_job = SharedRenderJob::new(render_job); + + output.set_render_job(Some(render_job.clone())); + + let job_state = futures_lite::future::block_on(executor.0.run(render_job)); + output.close()?; + handle.join().unwrap()?; + + match job_state { + RenderJobState::Waiting | RenderJobState::Rendering | RenderJobState::Paused => { + return Err(Report::msg( + "Render job still in progress when it should be finished", + )); + } + RenderJobState::Complete { end_time } => { + writeln!(term, "Finished rendering in {end_time:.0} seconds")?; + term.flush()?; + } + RenderJobState::Error(err) => { + return Err(err.into()); + } + } + + Ok(()) +} + +#[derive(Clone, Debug)] +struct CliExecutor(Arc>); + +impl ApplessExecutor for CliExecutor { + fn spawn( + &self, + future: impl std::future::Future> + 'static + Send, + ) { + let inner = &self.0; + inner + .spawn(async { + // TODO: display these errors + if let Some(cb) = future.await { + cb() + } else { + Ok(()) + } + }) + .detach(); + } +} + +#[derive(Debug, Clone)] +struct CliOutputInner { + sender: Sender, + render_job: Option, + start_time: Instant, + term: Term, +} + +#[derive(Clone, Debug)] +struct CliOutput { + inner: Arc>, +} + +impl CliOutput { + fn new(term: Term) -> (Self, JoinHandle>) { + let (sender, receiver) = std::sync::mpsc::channel::(); + let inner = Arc::new(Mutex::new(CliOutputInner { + sender, + render_job: None, + start_time: Instant::now(), + term, + })); + let inner_for_handle = inner.clone(); + let handle = thread::spawn(move || loop { + match receiver.recv_timeout(Duration::from_secs_f64(1.0 / 30.0)) { + Ok(false) | Err(RecvTimeoutError::Timeout) => {} + _ => break Ok(()), + } + let inner = &mut *inner_for_handle.lock().unwrap(); + if let Some(job) = inner.render_job.as_ref() { + let mut job = job.lock(); + let is_in_progress = { + let state = &*job.state.lock().unwrap(); + matches!( + state, + RenderJobState::Paused + | RenderJobState::Rendering + | RenderJobState::Waiting + ) + }; + if is_in_progress { + let progress = job.update_progress(inner); + let eta = job.estimated_time_remaining; + + inner.term.clear_line()?; + inner.term.hide_cursor()?; + + if let (Some(position), Some(duration)) = (progress.position, progress.duration) + { + Self::draw_progress( + &mut inner.term, + position, + duration, + progress.progress, + eta, + )?; + } + + inner.term.flush()?; + } + } + }); + + (Self { inner }, handle) + } + + fn close(&self) -> Result<()> { + let inner = self.inner.lock().unwrap(); + inner.sender.send(true)?; + inner.term.clear_line()?; + inner.term.show_cursor()?; + inner.term.flush()?; + Ok(()) + } + + fn set_render_job(&self, job: Option) { + self.inner.lock().unwrap().render_job = job; + } + + fn draw_progress( + term: &mut impl Write, + position: ClockTime, + duration: ClockTime, + progress: f64, + eta: Option, + ) -> io::Result<()> { + write!( + term, + "{} {:.2} / {:.2} | {:.0}%", + Self::progress_bar(40, progress), + position, + duration, + progress * 100.0 + )?; + + if let Some(eta) = eta { + write!(term, " | {:.0} seconds remaining", eta)?; + } + + Ok(()) + } + + fn progress_bar(width: usize, progress: f64) -> StyledObject { + let mut bar = String::new(); + let completed_width = (width as f64 * progress).min(width as f64); + let num_blocks = completed_width as usize; + let block_part = (completed_width.fract() * 8.0) as usize; + + bar.push_str(&"█".repeat(num_blocks)); + let partial_block = match block_part { + 0 => ' ', + 1 => '▏', + 2 => '▎', + 3 => '▍', + 4 => '▌', + 5 => '▋', + 6 => '▊', + 7 => '▉', + _ => ' ', + }; + bar.push(partial_block); + bar.push_str(&" ".repeat((width - num_blocks).saturating_sub(1))); + + console::style(bar).bg(console::Color::Color256(8)) + } +} + +impl UIContext for CliOutput { + fn request_repaint(&self) { + self.inner.lock().unwrap().request_repaint() + } + + fn current_time(&self) -> f64 { + self.inner.lock().unwrap().current_time() + } +} + +impl UIContext for CliOutputInner { + fn request_repaint(&self) { + let _ = self.sender.send(false); + } + + fn current_time(&self) -> f64 { + (Instant::now() - self.start_time).as_secs_f64() + } +} diff --git a/crates/gui/src/gst_utils/init.rs b/crates/gui/src/gst_utils/init.rs new file mode 100644 index 0000000..db17381 --- /dev/null +++ b/crates/gui/src/gst_utils/init.rs @@ -0,0 +1,41 @@ +use gstreamer::prelude::*; + +use super::{elements, gstreamer_error::GstreamerError}; + +pub fn initialize_gstreamer() -> Result<(), GstreamerError> { + gstreamer::init()?; + + gstreamer::Element::register( + None, + "eguisink", + gstreamer::Rank::NONE, + elements::EguiSink::static_type(), + )?; + + gstreamer::Element::register( + None, + "ntscfilter", + gstreamer::Rank::NONE, + elements::NtscFilter::static_type(), + )?; + + gstreamer::Element::register( + None, + "videopadfilter", + gstreamer::Rank::NONE, + elements::VideoPadFilter::static_type(), + )?; + + // PulseAudio has a severe bug that will greatly delay initial playback to the point of unusability: + // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/1383 + // A fix was merged a *year* ago, but the Pulse devs, in their infinite wisdom, won't give it to us until their + // next major release, the first RC of which will apparently arrive "soon": + // https://gitlab.freedesktop.org/pulseaudio/pulseaudio/-/issues/3757#note_2038416 + // Until then, disable it and pray that someone writes a PipeWire sink so we don't have to deal with any more + // bugs like this + if let Some(sink) = gstreamer::ElementFactory::find("pulsesink") { + sink.set_rank(gstreamer::Rank::NONE); + } + + Ok(()) +} diff --git a/crates/gui/src/gst_utils/mod.rs b/crates/gui/src/gst_utils/mod.rs index 816f289..58de387 100644 --- a/crates/gui/src/gst_utils/mod.rs +++ b/crates/gui/src/gst_utils/mod.rs @@ -1,6 +1,7 @@ pub mod clock_format; pub mod egui_sink; pub mod gstreamer_error; +pub mod init; pub mod ntsc_pipeline; pub mod ntscrs_filter; pub mod process_gst_frame; diff --git a/crates/gui/src/gst_utils/ntsc_pipeline.rs b/crates/gui/src/gst_utils/ntsc_pipeline.rs index 80bf8e0..50e23c7 100644 --- a/crates/gui/src/gst_utils/ntsc_pipeline.rs +++ b/crates/gui/src/gst_utils/ntsc_pipeline.rs @@ -11,10 +11,12 @@ use gstreamer_video::{VideoCapsBuilder, VideoInterlaceMode}; use log::debug; use serde::{Deserialize, Serialize}; use std::{ - cell::Cell, error::Error, fmt::Display, - sync::{atomic::AtomicBool, Arc, Mutex}, + sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, Mutex, + }, }; #[derive(Clone, Debug, glib::Boxed)] @@ -107,7 +109,7 @@ impl VideoScaleFilter { pub struct NtscPipeline { pub inner: Pipeline, /// Incremented and set as a custom property on the caps filter to force renegotiation. - caps_generation: Cell, + caps_generation: Arc, } impl NtscPipeline { @@ -459,7 +461,7 @@ impl NtscPipeline { Ok(Self { inner: pipeline, - caps_generation: Cell::new(0), + caps_generation: Arc::new(AtomicU32::new(0)), }) } @@ -520,8 +522,12 @@ impl NtscPipeline { return Ok(()); }; - let caps_generation = self.caps_generation.get().wrapping_add(1); - self.caps_generation.set(caps_generation); + let caps_generation = self + .caps_generation + .load(std::sync::atomic::Ordering::Acquire) + .wrapping_add(1); + self.caps_generation + .store(caps_generation, std::sync::atomic::Ordering::Release); if let Some((dst_width, dst_height)) = scale_from_caps(&scale_caps, scale.scanlines) { caps_filter.set_property( "caps", diff --git a/crates/gui/src/widgets/render_job.rs b/crates/gui/src/widgets/render_job.rs index 9511386..5fc6cfd 100644 --- a/crates/gui/src/widgets/render_job.rs +++ b/crates/gui/src/widgets/render_job.rs @@ -7,7 +7,7 @@ use snafu::ResultExt; use crate::{ app::{ error::{ApplicationError, RenderJobPipelineSnafu}, - render_job::{RenderJob, RenderJobState}, + render_job::{RenderJob, RenderJobProgress, RenderJobState}, }, gst_utils::gstreamer_error::GstreamerError, }; @@ -47,36 +47,11 @@ impl RenderJobWidget<'_> { .show(ui, |ui| { let job_state = job.state.lock().unwrap().clone(); - let (progress, job_position, job_duration) = match job_state { - RenderJobState::Waiting => (0.0, None, None), - RenderJobState::Paused - | RenderJobState::Rendering - | RenderJobState::Error(_) => { - let job_position = job.pipeline.query_position::(); - let job_duration = job.pipeline.query_duration::(); - - ( - if let (Some(job_position), Some(job_duration)) = - (job_position, job_duration) - { - job_position.nseconds() as f64 / job_duration.nseconds() as f64 - } else { - job.last_progress - }, - job_position, - job_duration, - ) - } - RenderJobState::Complete { .. } => (1.0, None, None), - }; - - if matches!( - job_state, - RenderJobState::Rendering | RenderJobState::Waiting - ) { - let current_time = ui.ctx().input(|input| input.time); - job.update_estimated_time_remaining(progress, current_time); - } + let RenderJobProgress { + progress, + position: job_position, + duration: job_duration, + } = job.update_progress(ui.ctx()); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -158,8 +133,6 @@ impl RenderJobWidget<'_> { ui.label(format!("Time remaining: {time_remaining:.0} seconds")); } } - - job.last_progress = progress; }); (closed, error) diff --git a/crates/ntscrs/src/settings/settings.rs b/crates/ntscrs/src/settings/settings.rs index 5c00827..67cf71b 100644 --- a/crates/ntscrs/src/settings/settings.rs +++ b/crates/ntscrs/src/settings/settings.rs @@ -223,7 +223,7 @@ macro_rules! impl_settings_for { } /// Menu item for a SettingKind::Enumeration. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct MenuItem { pub label: &'static str, pub description: Option<&'static str>, @@ -232,7 +232,7 @@ pub struct MenuItem { /// All of the types a setting can take. API consumers can map this to the UI elements available in whatever they're /// porting ntsc-rs to. -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum SettingKind { /// Selection of specific options, preferably in a specific order. Enumeration { @@ -344,7 +344,7 @@ pub trait Settings: Default { /// A single setting, which includes the data common to all settings (its name, optional description/tooltip, and ID) /// along with a SettingKind which contains data specific to the type of setting. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SettingDescriptor { pub label: &'static str, pub description: Option<&'static str>, @@ -430,6 +430,7 @@ impl GetAndExpect for HashMap { } /// Introspectable list of settings and their types and ranges. +#[derive(Debug, Clone)] pub struct SettingsList { pub settings: Box<[SettingDescriptor]>, } diff --git a/crates/ntscrs/src/settings/standard.rs b/crates/ntscrs/src/settings/standard.rs index b7800a6..a750ee2 100644 --- a/crates/ntscrs/src/settings/standard.rs +++ b/crates/ntscrs/src/settings/standard.rs @@ -31,6 +31,13 @@ impl UseField { UseField::InterleavedLower => YiqField::InterleavedLower, } } + + pub fn interlaced_output_allowed(&self) -> bool { + matches!( + self, + UseField::InterleavedUpper | UseField::InterleavedLower + ) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)] diff --git a/xtask/src/macos_bundle.rs b/xtask/src/macos_bundle.rs index 055ee06..e9f320e 100644 --- a/xtask/src/macos_bundle.rs +++ b/xtask/src/macos_bundle.rs @@ -38,7 +38,7 @@ pub fn command() -> clap::Command { ) } -/// Build the plugin for a given target, in either debug or release mode. This is called once in most cases, but when +/// Build the app for a given target, in either debug or release mode. This is called once in most cases, but when /// creating a macOS universal binary, it's called twice--once per architecture. /// This returns the path to the built binary. fn build_for_target(target: &str, release_mode: bool) -> std::io::Result { @@ -72,10 +72,7 @@ fn build_for_target(target: &str, release_mode: bool) -> std::io::Result Result<(), Box> { // Build x86_64 and aarch64 binaries. // TODO: unlike the other macOS xtasks, this doesn't yet support choosing the targets. println!("Building binaries..."); - let x86_64_path = build_for_target("x86_64-apple-darwin", release_mode)?; - let aarch64_path = build_for_target("aarch64-apple-darwin", release_mode)?; + let x86_64_dir = build_for_target("x86_64-apple-darwin", release_mode)?; + let aarch64_dir = build_for_target("aarch64-apple-darwin", release_mode)?; // Extract gui version from Cargo.toml. println!("Getting version for Info.plist and creating bundle directories..."); @@ -179,20 +176,22 @@ pub fn main(args: &clap::ArgMatches) -> Result<(), Box> { plist::Value::Dictionary(info_plist_contents) .to_file_xml(contents_dir_path.plus("Info.plist"))?; - let app_executable_path = macos_dir_path.plus("ntsc-rs-standalone"); - - println!("Creating universal binary..."); - // Combine x86_64 and aarch64 binaries and place the result in the bundle. - Command::new("lipo") - .args(&[ - OsString::from("-create"), - OsString::from("-output"), - app_executable_path.clone().into(), - x86_64_path.into(), - aarch64_path.into(), - ]) - .status() - .expect_success()?; + let app_executables = ["ntsc-rs-standalone", "ntsc-rs-cli"]; + + for binary_name in app_executables { + println!("Creating universal binary ({binary_name})..."); + // Combine x86_64 and aarch64 binaries and place the result in the bundle. + Command::new("lipo") + .args(&[ + OsString::from("-create"), + OsString::from("-output"), + macos_dir_path.plus(binary_name).into(), + x86_64_dir.plus(binary_name).into(), + aarch64_dir.plus(binary_name).into(), + ]) + .status() + .expect_success()?; + } // Copy gstreamer libraries into the bundle. println!("Copying gstreamer libraries..."); @@ -241,15 +240,19 @@ pub fn main(args: &clap::ArgMatches) -> Result<(), Box> { // and it *seems* to work fine. GStreamer includes many binaries which would also need to be `install_name_tool`'d // and apparently the paths *to* those binaries need to be properly set via environment variables(?), but we don't // copy any binaries anyway (see the above recursive copy, which only copies .dylibs), so hopefully it's okay. - println!("Adding gstreamer rpath..."); - Command::new("install_name_tool") - .args([ - OsString::from("-add_rpath"), - OsString::from("@executable_path/../Frameworks/GStreamer.framework/Versions/1.0/lib"), - OsString::from(&app_executable_path), - ]) - .status() - .expect_success()?; + for binary_name in app_executables { + println!("Adding gstreamer rpath ({binary_name})..."); + Command::new("install_name_tool") + .args([ + OsString::from("-add_rpath"), + OsString::from( + "@executable_path/../Frameworks/GStreamer.framework/Versions/1.0/lib", + ), + OsString::from(macos_dir_path.plus(binary_name)), + ]) + .status() + .expect_success()?; + } // Create the iconset. Adapted from https://stackoverflow.com/a/20703594.