use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use crate::clock::{Monotonic, SharedClock};
use crate::color::parse_hex_color;
use crate::compositor::Compositor;
use crate::error::{Error, Result};
use crate::frame::Frame;
use crate::geometry::{Fit, Rect};
use crate::output::Output;
use crate::output_layer::OutputLayer;
use crate::placement::Monitor;
use crate::render::{Gpu, Renderer};
use crate::video_source::{FrameCache, VideoSource};
pub type OutputRect = (i64, i64, i64, i64);
#[derive(Clone, Default)]
pub enum Audio {
#[default]
Off,
Follow(SharedClock),
#[cfg(feature = "atome")]
Atome(atome::output::Scheduler<f32>),
}
impl Audio {
#[cfg(feature = "atome")]
pub fn atome(output: &atome::output::OutputClass<f32>) -> Self {
Audio::Atome(output.scheduler())
}
pub fn is_enabled(&self) -> bool {
!matches!(self, Audio::Off)
}
pub(crate) fn timeline(&self) -> SharedClock {
match self {
Audio::Off => Monotonic::shared(),
Audio::Follow(clock) => Arc::clone(clock),
#[cfg(feature = "atome")]
Audio::Atome(scheduler) => Arc::new(scheduler.clock()),
}
}
}
impl std::fmt::Debug for Audio {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Audio::Off => formatter.write_str("Audio::Off"),
Audio::Follow(clock) => formatter
.debug_struct("Audio::Follow")
.field("position", &clock.position())
.field("running", &clock.is_running())
.finish(),
#[cfg(feature = "atome")]
Audio::Atome(scheduler) => formatter
.debug_struct("Audio::Atome")
.field("channels", &scheduler.channels())
.field("sample_rate", &scheduler.sample_rate())
.finish(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ClipId(pub u64);
impl std::fmt::Display for ClipId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "clip {}", self.0)
}
}
impl ClipId {
fn key(self) -> String {
format!("clip-{}", self.0)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Hold {
#[default]
Forever,
Frames(u64),
Time(Duration),
}
#[derive(Clone, Debug)]
pub struct Clip {
media: Media,
monitor: String,
area: Option<Rect>,
fit: Fit,
opacity: f32,
z_index: i32,
hold: Hold,
looping: bool,
start_at: Option<Duration>,
audio: Option<AudioMedia>,
}
#[derive(Clone, Debug)]
enum Media {
Video(PathBuf),
VideoReader(ReaderSlot, String),
Image(PathBuf),
Frame(Frame),
}
#[derive(Clone, Debug)]
enum AudioMedia {
Path(PathBuf),
Reader(ReaderSlot, String),
}
#[derive(Clone)]
pub(crate) struct ReaderSlot(Arc<Mutex<Option<Box<dyn crate::ReadSeek>>>>);
impl ReaderSlot {
fn new(reader: impl crate::ReadSeek + 'static) -> Self {
ReaderSlot(Arc::new(Mutex::new(Some(Box::new(reader)))))
}
fn take(&self, name: &str) -> Result<Box<dyn crate::ReadSeek>> {
lock(&self.0)
.take()
.ok_or_else(|| Error::unsupported(format!("{name}: this reader was already given to a clip that was added")))
}
}
impl std::fmt::Debug for ReaderSlot {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ReaderSlot(..)")
}
}
#[derive(Clone, Debug)]
#[cfg_attr(not(feature = "atome"), allow(dead_code))]
pub(crate) enum SoundSource {
Path(PathBuf),
Reader(SharedReader, String),
}
#[derive(Clone)]
pub(crate) struct SharedReader(Arc<Mutex<Box<dyn crate::ReadSeek>>>);
impl std::fmt::Debug for SharedReader {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SharedReader(..)")
}
}
impl std::io::Read for SharedReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
lock(&self.0).read(buffer)
}
}
impl std::io::Seek for SharedReader {
fn seek(&mut self, position: std::io::SeekFrom) -> std::io::Result<u64> {
lock(&self.0).seek(position)
}
}
impl AudioMedia {
fn open(self) -> Result<SoundSource> {
Ok(match self {
AudioMedia::Path(path) => SoundSource::Path(path),
AudioMedia::Reader(slot, name) => {
let reader = slot.take(&name)?;
SoundSource::Reader(SharedReader(Arc::new(Mutex::new(reader))), name)
}
})
}
}
impl Clip {
fn with(media: Media, monitor: impl Into<String>) -> Self {
Clip {
media,
monitor: monitor.into(),
area: None,
fit: Fit::Contain,
opacity: 1.0,
z_index: 0,
hold: Hold::Forever,
looping: false,
start_at: None,
audio: None,
}
}
pub fn video(path: impl Into<PathBuf>, monitor: impl Into<String>) -> Self {
Clip::with(Media::Video(path.into()), monitor)
}
pub fn video_reader(reader: impl crate::ReadSeek + 'static, name: impl Into<String>, monitor: impl Into<String>) -> Self {
Clip::with(Media::VideoReader(ReaderSlot::new(reader), name.into()), monitor)
}
pub fn image(path: impl Into<PathBuf>, monitor: impl Into<String>) -> Self {
Clip::with(Media::Image(path.into()), monitor)
}
pub fn frame(frame: Frame, monitor: impl Into<String>) -> Self {
Clip::with(Media::Frame(frame), monitor)
}
pub fn area(mut self, area: Rect) -> Self {
self.area = Some(area);
self
}
pub fn fit(mut self, fit: Fit) -> Self {
self.fit = fit;
self
}
pub fn opacity(mut self, opacity: f32) -> Self {
self.opacity = opacity.clamp(0.0, 1.0);
self
}
pub fn z_index(mut self, z_index: i32) -> Self {
self.z_index = z_index;
self
}
pub fn hold(mut self, hold: Hold) -> Self {
self.hold = hold;
self
}
pub fn duration(self, duration: Duration) -> Self {
self.hold(Hold::Time(duration))
}
pub fn looping(mut self, looping: bool) -> Self {
self.looping = looping;
self
}
pub fn start_at(mut self, position: Duration) -> Self {
self.start_at = Some(position);
self
}
pub fn audio(mut self, path: impl Into<PathBuf>) -> Self {
self.audio = Some(AudioMedia::Path(path.into()));
self
}
pub fn audio_reader(mut self, reader: impl crate::ReadSeek + 'static, name: impl Into<String>) -> Self {
self.audio = Some(AudioMedia::Reader(ReaderSlot::new(reader), name.into()));
self
}
}
#[derive(Clone, Debug)]
pub struct ClipEnded {
pub id: ClipId,
pub error: Option<String>,
}
pub(crate) struct Prepared {
content: Content,
monitor: String,
area: Option<Rect>,
fit: Fit,
opacity: f32,
z_index: i32,
looping: bool,
start_at: Option<Duration>,
#[cfg_attr(not(feature = "atome"), allow(dead_code))]
soundtrack: Option<SoundSource>,
#[cfg_attr(not(feature = "atome"), allow(dead_code))]
duration: Duration,
}
enum Content {
Video(VideoSource),
Still { frame: Frame, frames: Option<u64> },
}
pub(crate) enum Command {
Add(ClipId, Box<Prepared>),
Stop(ClipId),
Snapshot(String, Sender<Result<Frame>>),
SnapshotClip(ClipId, Sender<Result<Frame>>),
Close,
}
#[derive(Default)]
pub(crate) struct Shared {
next_id: AtomicU64,
running: Mutex<Option<HashSet<String>>>,
skipped: Mutex<Vec<(String, String)>>,
active: Mutex<HashSet<ClipId>>,
finished: Mutex<HashSet<ClipId>>,
ended: Mutex<Vec<ClipEnded>>,
closed: AtomicBool,
frames: AtomicU64,
timeline: Mutex<Option<SharedClock>>,
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Clone)]
pub struct Controls {
commands: Sender<Command>,
shared: Arc<Shared>,
frame_rate: f64,
}
impl Controls {
pub(crate) fn new(commands: Sender<Command>, shared: Arc<Shared>, frame_rate: f64) -> Self {
Controls {
commands,
shared,
frame_rate,
}
}
pub fn add(&self, clip: Clip) -> Result<ClipId> {
self.add_with(clip, None)
}
#[cfg(feature = "demux")]
pub fn add_generic(
&self,
path: impl Into<PathBuf>,
monitor: impl Into<String>,
) -> Result<ClipId> {
let path = path.into();
let container = crate::identify::identify_path(&path)?;
let clip = if container.is_image() {
Clip::image(path, monitor)
} else {
Clip::video(path, monitor)
};
self.add(clip)
}
pub fn add_cached(
&self,
clip: Clip,
ranges: Vec<(u64, u64)>,
frames: Vec<Frame>,
) -> Result<ClipId> {
if !matches!(clip.media, Media::Video(_) | Media::VideoReader(..)) {
return Err(Error::unsupported(
"only a video has frames to cache; this clip is a still",
));
}
let cache = FrameCache::new(ranges, frames)?;
self.add_with(clip, Some(cache))
}
fn add_with(&self, clip: Clip, cache: Option<FrameCache>) -> Result<ClipId> {
if self.shared.closed.load(Ordering::Acquire) {
return Err(Error::unsupported("the engine has closed"));
}
if let Some(running) = lock(&self.shared.running).as_ref() {
if !running.contains(&clip.monitor) {
return Err(Error::NoSuchMonitor {
selector: clip.monitor.clone(),
available: running.len(),
});
}
}
let mut soundtrack = None;
let mut duration = Duration::ZERO;
let content = match clip.media {
Media::Video(path) => {
let mut source = VideoSource::from_file(&path)?;
soundtrack = if source.info().has_audio() {
Some(SoundSource::Path(path))
} else {
clip.audio.map(AudioMedia::open).transpose()?
};
duration = source.info().duration;
if let Some(cache) = cache {
source.set_cache(cache)?;
}
Content::Video(source)
}
Media::VideoReader(slot, name) => {
let mut source = VideoSource::from_reader(slot.take(&name)?, &name)?;
soundtrack = clip.audio.map(AudioMedia::open).transpose()?;
duration = source.info().duration;
if let Some(cache) = cache {
source.set_cache(cache)?;
}
Content::Video(source)
}
Media::Image(path) => Content::Still {
frames: self.frames_for(clip.hold)?,
frame: crate::still::load_image(path)?,
},
Media::Frame(frame) => Content::Still {
frames: self.frames_for(clip.hold)?,
frame,
},
};
let id = ClipId(self.shared.next_id.fetch_add(1, Ordering::Relaxed));
lock(&self.shared.active).insert(id);
let prepared = Prepared {
content,
monitor: clip.monitor,
area: clip.area,
fit: clip.fit,
opacity: clip.opacity,
z_index: clip.z_index,
looping: clip.looping,
start_at: clip.start_at,
soundtrack,
duration,
};
self.commands
.send(Command::Add(id, Box::new(prepared)))
.map_err(|_| Error::unsupported("the engine has closed"))?;
Ok(id)
}
pub fn add_image(
&self,
path: impl Into<PathBuf>,
monitor: impl Into<String>,
hold: Hold,
) -> Result<ClipId> {
self.add(Clip::image(path, monitor).hold(hold))
}
pub fn add_frame(&self, frame: Frame, monitor: impl Into<String>, hold: Hold) -> Result<ClipId> {
self.add(Clip::frame(frame, monitor).hold(hold))
}
pub fn stop(&self, id: ClipId) -> bool {
if !lock(&self.shared.active).remove(&id) {
return false;
}
lock(&self.shared.finished).insert(id);
self.commands.send(Command::Stop(id)).is_ok()
}
pub fn is_running(&self, id: ClipId) -> bool {
lock(&self.shared.active).contains(&id)
}
pub fn is_finished(&self, id: ClipId) -> bool {
lock(&self.shared.finished).contains(&id)
}
pub fn position(&self) -> Duration {
lock(&self.shared.timeline)
.as_ref()
.map_or(Duration::ZERO, |timeline| timeline.position())
}
pub fn take_ended(&self) -> Vec<ClipEnded> {
std::mem::take(&mut *lock(&self.shared.ended))
}
pub fn running_monitors(&self) -> Vec<String> {
let mut monitors: Vec<String> = lock(&self.shared.running)
.iter()
.flatten()
.cloned()
.collect();
monitors.sort();
monitors
}
pub fn skipped_monitors(&self) -> Vec<(String, String)> {
lock(&self.shared.skipped).clone()
}
pub fn snapshot(&self, id: ClipId) -> Result<Frame> {
self.ask(|reply| Command::SnapshotClip(id, reply))
}
pub fn snapshot_output(&self, monitor: &str) -> Result<Frame> {
let monitor = monitor.to_string();
self.ask(|reply| Command::Snapshot(monitor, reply))
}
fn ask(&self, command: impl FnOnce(Sender<Result<Frame>>) -> Command) -> Result<Frame> {
let (reply, answer) = std::sync::mpsc::channel();
self.commands
.send(command(reply))
.map_err(|_| Error::unsupported("the engine has closed"))?;
answer
.recv_timeout(Duration::from_secs(1))
.map_err(|_| Error::Render {
reason: "the engine did not answer — is it running?".to_string(),
})?
}
pub fn close(&self) {
self.shared.closed.store(true, Ordering::Release);
let _ = self.commands.send(Command::Close);
}
pub fn frames(&self) -> u64 {
self.shared.frames.load(Ordering::Relaxed)
}
pub fn is_closed(&self) -> bool {
self.shared.closed.load(Ordering::Acquire)
}
fn frames_for(&self, hold: Hold) -> Result<Option<u64>> {
match hold {
Hold::Forever => Ok(None),
Hold::Frames(0) => Err(Error::unsupported(
"an image held for 0 frames would never be on screen",
)),
Hold::Frames(frames) => Ok(Some(frames)),
Hold::Time(duration) if duration.is_zero() => Err(Error::unsupported(
"an image held for no time would never be on screen",
)),
Hold::Time(duration) => Ok(Some(self.frames_in(duration))),
}
}
fn frames_in(&self, duration: Duration) -> u64 {
((duration.as_secs_f64() * self.frame_rate).ceil() as u64).max(1)
}
}
#[derive(Clone)]
pub(crate) struct Planned {
pub(crate) monitor: String,
#[cfg_attr(any(target_os = "ios", target_os = "android"), allow(dead_code))]
pub(crate) x: i32,
#[cfg_attr(any(target_os = "ios", target_os = "android"), allow(dead_code))]
pub(crate) y: i32,
pub(crate) width: u32,
pub(crate) height: u32,
background: Background,
}
#[derive(Clone)]
enum Background {
Color([f32; 4]),
Image(String),
}
pub(crate) fn plan(
outputs: &HashMap<String, OutputRect>,
backgrounds: &HashMap<String, String>,
excluded: &HashSet<String>,
attached: &[Monitor],
shared: &Shared,
) -> Result<Vec<Planned>> {
let mut planned = Vec::new();
let mut skipped = Vec::new();
let mut requested: Vec<(&String, &OutputRect)> = outputs.iter().collect();
requested.sort();
for (id, &(width, height, x, y)) in requested {
if excluded.contains(id) {
skipped.push((id.clone(), "excluded".to_string()));
continue;
}
let Some(monitor) = attached.iter().find(|monitor| &monitor.persistent_id == id) else {
skipped.push((id.clone(), "not attached".to_string()));
continue;
};
let (Ok(width), Ok(height)) = (u32::try_from(width), u32::try_from(height)) else {
skipped.push((id.clone(), format!("{width}×{height} is not a size")));
continue;
};
if width == 0 || height == 0 {
skipped.push((id.clone(), format!("{width}×{height} has no area")));
continue;
}
let to_desktop = |origin: f64, offset: i64| i32::try_from(origin as i64 + offset).ok();
let (Some(x), Some(y)) = (
to_desktop(monitor.bounds.x, x),
to_desktop(monitor.bounds.y, y),
) else {
skipped.push((id.clone(), "the position is off the desktop".to_string()));
continue;
};
let background = match backgrounds.get(id).map(|text| text.trim()) {
None | Some("") => Background::Color([0.0; 4]),
Some(color) if color.starts_with('#') => Background::Color(parse_hex_color(color)?),
Some(path) => {
crate::still::load_image(path)?;
Background::Image(path.to_string())
}
};
planned.push(Planned {
monitor: id.clone(),
x,
y,
width,
height,
background,
});
}
*lock(&shared.skipped) = skipped;
if planned.is_empty() {
return Err(Error::NoSuchMonitor {
selector: format!(
"any of the {} monitor{} given, once excluded and unattached ones are left out",
outputs.len(),
if outputs.len() == 1 { "" } else { "s" }
),
available: attached.len(),
});
}
*lock(&shared.running) = Some(planned.iter().map(|plan| plan.monitor.clone()).collect());
Ok(planned)
}
pub(crate) struct Screen<W> {
pub(crate) monitor: String,
output: usize,
pub(crate) window: W,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
}
impl<W> Screen<W> {
pub(crate) fn new(
plan: &Planned,
window: W,
surface: wgpu::Surface<'static>,
gpu: &Gpu,
) -> Self {
let capabilities = surface.get_capabilities(&gpu.adapter);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: crate::render::surface_format(&capabilities),
color_space: wgpu::SurfaceColorSpace::Srgb,
width: plan.width,
height: plan.height,
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: crate::render::see_through(&capabilities),
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&gpu.device, &config);
Screen {
monitor: plan.monitor.clone(),
output: usize::MAX,
window,
surface,
config,
}
}
#[cfg_attr(not(feature = "tauri"), allow(dead_code))]
pub(crate) fn into_window(self) -> W {
drop(self.surface);
self.window
}
}
pub(crate) struct Core<W> {
compositor: Compositor,
gpu: Gpu,
renderers: HashMap<wgpu::TextureFormat, Renderer>,
screens: Vec<Screen<W>>,
commands: Receiver<Command>,
shared: Arc<Shared>,
#[cfg_attr(not(feature = "atome"), allow(dead_code))]
audio: Audio,
#[cfg(feature = "atome")]
soundtracks: HashMap<ClipId, crate::audio::Soundtrack>,
}
#[cfg(feature = "atome")]
const PREROLL: Duration = Duration::from_millis(250);
impl<W> Core<W> {
pub(crate) fn new(
gpu: Gpu,
plans: &[Planned],
mut screens: Vec<Screen<W>>,
commands: Receiver<Command>,
shared: Arc<Shared>,
audio: &Audio,
) -> Result<Self> {
let timeline = audio.timeline();
*lock(&shared.timeline) = Some(Arc::clone(&timeline));
let mut compositor = Compositor::with_clock(timeline);
let mut renderers = HashMap::new();
for screen in &mut screens {
let plan = plans
.iter()
.find(|plan| plan.monitor == screen.monitor)
.ok_or_else(|| Error::unsupported("a screen with no plan behind it"))?;
let output = Output::new(plan.width, plan.height);
let output = match &plan.background {
Background::Color(color) => output.with_background_color(*color),
Background::Image(path) => output
.with_transparent_background()
.with_background_image(path.clone()),
};
screen.output = compositor.add_output(output);
if let std::collections::hash_map::Entry::Vacant(slot) =
renderers.entry(screen.config.format)
{
slot.insert(Renderer::new(&gpu, screen.config.format)?);
}
}
Ok(Core {
compositor,
gpu,
renderers,
screens,
commands,
shared,
audio: audio.clone(),
#[cfg(feature = "atome")]
soundtracks: HashMap::new(),
})
}
pub(crate) fn refresh(&mut self) -> Result<bool> {
while let Ok(command) = self.commands.try_recv() {
match command {
Command::Add(id, prepared) => self.place(id, *prepared),
Command::Stop(id) => {
self.compositor.remove_source(&id.key());
#[cfg(feature = "atome")]
self.soundtracks.remove(&id);
}
Command::Snapshot(monitor, reply) => {
let _ = reply.send(self.snapshot(&monitor));
}
Command::SnapshotClip(id, reply) => {
let _ = reply.send(self.snapshot_clip(id));
}
Command::Close => return Ok(false),
}
}
for finished in self.compositor.tick() {
if let Some(id) = parse_key(&finished.id) {
self.finish(id, finished.error.map(|error| error.to_string()));
}
}
for screen in &mut self.screens {
use wgpu::CurrentSurfaceTexture;
let texture = match screen.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
CurrentSurfaceTexture::Outdated | CurrentSurfaceTexture::Lost => {
screen.surface.configure(&self.gpu.device, &screen.config);
continue;
}
CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => continue,
other => {
return Err(Error::Render {
reason: format!("no surface texture for {}: {other:?}", screen.monitor),
})
}
};
let view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let renderer = &self.renderers[&screen.config.format];
self.compositor.draw_output(
screen.output,
&self.gpu,
renderer,
&view,
screen.config.width,
screen.config.height,
)?;
self.gpu.queue.present(texture);
}
self.compositor.presented();
self.shared.frames.fetch_add(1, Ordering::Relaxed);
Ok(true)
}
fn place(&mut self, id: ClipId, prepared: Prepared) {
let Some(screen) = self
.screens
.iter()
.find(|screen| screen.monitor == prepared.monitor)
else {
self.finish(
id,
Some(format!("no output is running on {}", prepared.monitor)),
);
return;
};
let output = screen.output;
let key = id.key();
let start_at = match prepared.content {
Content::Video(_) => self.soundtrack(id, &prepared).or(prepared.start_at),
Content::Still { .. } => None,
};
match prepared.content {
Content::Video(source) => {
self.compositor
.insert_video_at(&key, source, prepared.looping, start_at);
}
Content::Still { frame, frames } => self.compositor.insert_still(&key, frame, frames),
}
if let Some(target) = self.compositor.get_output_mut(output) {
let area = prepared.area.unwrap_or_else(|| {
Rect::from_size(f64::from(target.width), f64::from(target.height))
});
target.add_layer(
OutputLayer::new(key, area)
.with_fit(prepared.fit)
.with_z_index(prepared.z_index)
.with_opacity(prepared.opacity),
);
}
}
fn snapshot(&mut self, monitor: &str) -> Result<Frame> {
let screen = self
.screens
.iter()
.find(|screen| screen.monitor == monitor)
.ok_or_else(|| Error::NoSuchMonitor {
selector: monitor.to_string(),
available: self.screens.len(),
})?;
let (width, height) = (screen.config.width, screen.config.height);
let renderer = &self.renderers[&screen.config.format];
let pixels = self
.compositor
.render_output_to_rgba(screen.output, &self.gpu, renderer, width, height)?;
Frame::packed(
width,
height,
crate::frame::PixelFormat::Rgba8,
crate::color::ColorSpace::srgb(),
Duration::ZERO,
pixels,
)
}
fn snapshot_clip(&self, id: ClipId) -> Result<Frame> {
self.compositor
.showing(&id.key())
.cloned()
.ok_or_else(|| {
Error::unsupported(format!(
"{id} has no picture up: it is not running, or its first frame is not due yet"
))
})
}
#[cfg(feature = "atome")]
fn soundtrack(&mut self, id: ClipId, prepared: &Prepared) -> Option<Duration> {
let Audio::Atome(scheduler) = &self.audio else {
return None;
};
let source = prepared.soundtrack.as_ref()?;
let at = prepared
.start_at
.unwrap_or_else(|| self.compositor.clock().position() + PREROLL);
let period = (prepared.looping && !prepared.duration.is_zero()).then_some(prepared.duration);
match crate::audio::Soundtrack::start(source, scheduler.clone(), at, period) {
Ok(soundtrack) => {
self.soundtracks.insert(id, soundtrack);
}
Err(error) => lock(&self.shared.ended).push(ClipEnded {
id,
error: Some(format!("playing without sound: {error}")),
}),
}
Some(at)
}
#[cfg(not(feature = "atome"))]
fn soundtrack(&mut self, _id: ClipId, _prepared: &Prepared) -> Option<Duration> {
None
}
fn finish(&mut self, id: ClipId, error: Option<String>) {
#[cfg(feature = "atome")]
self.soundtracks.remove(&id);
lock(&self.shared.active).remove(&id);
lock(&self.shared.finished).insert(id);
lock(&self.shared.ended).push(ClipEnded { id, error });
}
#[cfg_attr(not(feature = "tauri"), allow(dead_code))]
pub(crate) fn into_screens(self) -> Vec<Screen<W>> {
self.screens
}
}
fn parse_key(key: &str) -> Option<ClipId> {
key.strip_prefix("clip-")?.parse().ok().map(ClipId)
}
pub(crate) fn channel(frame_rate: f64) -> (Controls, Receiver<Command>, Arc<Shared>) {
let (sender, receiver) = std::sync::mpsc::channel();
let shared = Arc::new(Shared::default());
(
Controls::new(sender, Arc::clone(&shared), frame_rate),
receiver,
shared,
)
}
pub(crate) fn sane_frame_rate(frame_rate: f64) -> f64 {
if frame_rate.is_finite() {
frame_rate.clamp(1.0, 240.0)
} else {
30.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor(name: &str, x: f64) -> Monitor {
Monitor::new(name, Rect::new(x, 0.0, 1920.0, 1080.0))
}
#[test]
fn a_plan_keeps_what_is_attached_and_not_excluded_and_says_why_for_the_rest() {
let left = monitor("LEFT", 0.0);
let right = monitor("RIGHT", 1920.0);
let attached = vec![left.clone(), right.clone()];
let outputs = HashMap::from([
(left.persistent_id.clone(), (1920, 1080, 0, 0)),
(right.persistent_id.clone(), (640, 360, 100, 50)),
("monitor_gone".to_string(), (1920, 1080, 0, 0)),
]);
let backgrounds = HashMap::from([(right.persistent_id.clone(), "#FF000080".to_string())]);
let excluded = HashSet::from([left.persistent_id.clone()]);
let shared = Shared::default();
let planned = plan(&outputs, &backgrounds, &excluded, &attached, &shared).unwrap();
assert_eq!(planned.len(), 1);
let only = &planned[0];
assert_eq!(only.monitor, right.persistent_id);
assert_eq!((only.x, only.y, only.width, only.height), (2020, 50, 640, 360));
assert!(matches!(only.background, Background::Color([1.0, 0.0, 0.0, _])));
let skipped = lock(&shared.skipped).clone();
assert!(skipped.contains(&(left.persistent_id.clone(), "excluded".to_string())));
assert!(skipped.contains(&("monitor_gone".to_string(), "not attached".to_string())));
assert_eq!(
lock(&shared.running).clone().unwrap(),
HashSet::from([right.persistent_id])
);
}
#[test]
fn nothing_left_to_open_is_an_error_rather_than_an_engine_with_no_screens() {
let only = monitor("ONLY", 0.0);
let outputs = HashMap::from([(only.persistent_id.clone(), (1920, 1080, 0, 0))]);
let excluded = HashSet::from([only.persistent_id.clone()]);
assert!(matches!(
plan(&outputs, &HashMap::new(), &excluded, &[only], &Shared::default()),
Err(Error::NoSuchMonitor { .. })
));
}
#[test]
fn a_background_that_is_neither_colour_nor_image_is_refused_at_start() {
let only = monitor("ONLY", 0.0);
let outputs = HashMap::from([(only.persistent_id.clone(), (1920, 1080, 0, 0))]);
let backgrounds =
HashMap::from([(only.persistent_id.clone(), "#notacolour".to_string())]);
assert!(plan(
&outputs,
&backgrounds,
&HashSet::new(),
std::slice::from_ref(&only),
&Shared::default()
)
.is_err());
let backgrounds =
HashMap::from([(only.persistent_id.clone(), "/no/such/image.png".to_string())]);
assert!(plan(&outputs, &backgrounds, &HashSet::new(), &[only], &Shared::default())
.is_err());
}
#[test]
fn an_image_duration_becomes_frames_at_the_engine_rate() {
let (controls, _receiver, _shared) = channel(30.0);
assert_eq!(controls.frames_in(Duration::from_secs(2)), 60);
assert_eq!(controls.frames_in(Duration::from_millis(10)), 1, "never zero");
assert_eq!(controls.frames_in(Duration::from_millis(1001)), 31, "rounded up");
}
fn two_by_two() -> Frame {
Frame::packed(
2,
2,
crate::PixelFormat::Rgba8,
crate::ColorSpace::srgb(),
Duration::ZERO,
vec![255; 16],
)
.unwrap()
}
#[test]
fn every_kind_of_hold_arrives_at_the_engine_as_frames() {
let (controls, receiver, _shared) = channel(30.0);
for (hold, expected) in [
(Hold::Frames(90), Some(90)),
(Hold::Time(Duration::from_secs(2)), Some(60)),
(Hold::Time(Duration::from_millis(1001)), Some(31)),
(Hold::Forever, None),
] {
let id = controls.add_frame(two_by_two(), "any", hold).unwrap();
assert!(controls.is_running(id));
let Ok(Command::Add(sent, prepared)) = receiver.try_recv() else {
panic!("{hold:?} never reached the engine");
};
let Content::Still { frames, .. } = prepared.content else {
panic!("a frame should be a still");
};
assert_eq!(sent, id);
assert_eq!(frames, expected, "{hold:?}");
}
}
#[test]
fn a_hold_of_nothing_is_refused_before_anything_is_queued() {
let (controls, receiver, _shared) = channel(30.0);
assert!(controls.add_frame(two_by_two(), "any", Hold::Frames(0)).is_err());
assert!(controls
.add_frame(two_by_two(), "any", Hold::Time(Duration::ZERO))
.is_err());
assert!(receiver.try_recv().is_err(), "a refused clip reached the engine");
}
#[test]
fn an_image_that_will_not_open_is_an_error_at_add_rather_than_a_gap_on_screen() {
let (controls, receiver, _shared) = channel(30.0);
assert!(controls
.add_image("/no/such/image.png", "any", Hold::Frames(10))
.is_err());
assert!(receiver.try_recv().is_err());
}
#[test]
fn stopping_is_answered_honestly() {
let (controls, _receiver, _shared) = channel(30.0);
let frame = Frame::packed(
2,
2,
crate::PixelFormat::Rgba8,
crate::ColorSpace::srgb(),
Duration::ZERO,
vec![255; 16],
)
.unwrap();
let id = controls.add(Clip::frame(frame, "any")).unwrap();
assert!(controls.is_running(id));
assert!(!controls.is_finished(id));
assert!(controls.stop(id));
assert!(!controls.stop(id), "already stopped");
assert!(!controls.stop(ClipId(999)), "never existed");
assert!(!controls.is_running(id) && controls.is_finished(id));
assert!(!controls.is_running(ClipId(999)) && !controls.is_finished(ClipId(999)));
}
#[test]
fn a_cache_is_checked_before_anything_is_queued() {
let (controls, receiver, _shared) = channel(30.0);
let still = controls.add_cached(Clip::frame(two_by_two(), "any"), vec![(0, 1)], vec![two_by_two()]);
assert!(matches!(still, Err(Error::Unsupported { .. })));
let short = controls.add_cached(Clip::video("/no/such/film.mp4", "any"), vec![(0, 3)], vec![two_by_two()]);
assert!(matches!(short, Err(Error::Unsupported { .. })), "three frames promised, one given");
assert!(receiver.try_recv().is_err());
}
#[test]
fn a_generic_file_is_shown_as_what_it_is() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("not-a-video.mp4");
let png = directory.path().join("x.png");
image::save_buffer(&png, &[255; 16], 2, 2, image::ExtendedColorType::Rgba8).unwrap();
std::fs::copy(&png, &path).unwrap();
let (controls, receiver, _shared) = channel(30.0);
let id = controls.add_generic(&path, "any").unwrap();
let Ok(Command::Add(sent, prepared)) = receiver.try_recv() else {
panic!("the file never reached the engine");
};
assert_eq!(sent, id);
assert!(matches!(prepared.content, Content::Still { frames: None, .. }), "until stopped");
}
#[test]
fn the_engine_position_is_zero_until_a_timeline_exists() {
let (controls, _receiver, shared) = channel(30.0);
assert_eq!(controls.position(), Duration::ZERO);
*lock(&shared.timeline) = Some(Audio::Off.timeline());
std::thread::sleep(Duration::from_millis(5));
assert!(controls.position() >= Duration::from_millis(5));
assert!(!Audio::Off.is_enabled());
}
#[test]
fn a_closed_engine_refuses_new_clips() {
let (controls, _receiver, _shared) = channel(30.0);
controls.close();
assert!(controls.is_closed());
assert!(controls.add(Clip::image("x.png", "any")).is_err());
}
#[test]
fn frame_rates_are_kept_sane() {
assert_eq!(sane_frame_rate(0.0), 1.0);
assert_eq!(sane_frame_rate(1000.0), 240.0);
assert_eq!(sane_frame_rate(f64::NAN), 30.0);
assert_eq!(sane_frame_rate(29.97), 29.97);
}
}