phonto 0.3.2

GPU-accelerated video wallpaper program for wayland compositors & MacOS
use gst::MessageView::*;
use std::path::Path;
use std::str::FromStr;
use std::sync::mpsc::SyncSender;

use anyhow::{Context, anyhow};
use gstreamer as gst;
use gstreamer::prelude::*;
use gstreamer_app as gst_app;
use gstreamer_gl as gst_gl;
use gstreamer_gl::prelude::*;
use gstreamer_gl_egl as gst_gl_egl;
use gstreamer_video as gst_video;

pub struct Frame {
    pub texture_id: u32,
    pub width: u32,
    pub height: u32,
}

pub fn run(
    path: &Path,
    gl_display: gst_gl::GLDisplay,
    gl_context: gst_gl::GLContext,
    tx: SyncSender<gst::Sample>,
) -> anyhow::Result<()> {
    let pipeline = build_pipeline(path).context("build decoder pipeline")?;

    let mut display_ctx = gst::Context::new("gst.gl.GLDisplay", true);
    display_ctx
        .get_mut()
        .expect("freshly created Context has a unique reference")
        .set_gl_display(&gl_display);

    pipeline.set_context(&display_ctx);

    let mut app_ctx = gst::Context::new("gst.gl.app_context", true);
    app_ctx
        .get_mut()
        .expect("freshly created Context has a unique reference")
        .structure_mut()
        .set("context", &gl_context);

    pipeline.set_context(&app_ctx);

    let appsink = pipeline
        .by_name("sink")
        .context("appsink not found in pipeline")?
        .downcast::<gst_app::AppSink>()
        .map_err(|_| anyhow!("`sink` is not an AppSink"))?;

    appsink.set_callbacks(
        gst_app::AppSinkCallbacks::builder()
            .new_sample(move |sink| {
                let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?;
                tx.send(sample).map_err(|_| gst::FlowError::Eos)?;
                Ok(gst::FlowSuccess::Ok)
            })
            .build(),
    );

    pipeline
        .set_state(gst::State::Playing)
        .context("set pipeline state to Playing")?;

    let bus = pipeline.bus().context("pipeline has no bus")?;
    for msg in bus.iter_timed(gst::ClockTime::NONE) {
        match msg.view() {
            Eos(_) => {
                pipeline
                    .seek_simple(
                        gst::SeekFlags::FLUSH | gst::SeekFlags::KEY_UNIT,
                        gst::ClockTime::ZERO,
                    )
                    .context("seek to loop")?;
            }
            Error(err) => {
                pipeline.set_state(gst::State::Null).ok();
                return Err(anyhow!(
                    "pipeline error from {:?}: {} ({:?})",
                    err.src().map(|s| s.path_string()),
                    err.error(),
                    err.debug()
                ));
            }
            _ => {}
        }
    }

    pipeline.set_state(gst::State::Null).ok();
    Ok(())
}

// Build the GStreamer pipeline programmatically so the file path is set via the
// `location` GObject property instead of being interpolated into a pipeline
// description string. `gst::parse::launch` treats `"` as a string delimiter and
// `!` as an element separator, so a filename containing those characters could
// otherwise inject additional elements into the pipeline.
fn build_pipeline(path: &Path) -> anyhow::Result<gst::Pipeline> {
    let pipeline = gst::Pipeline::default();

    let filesrc = gst::ElementFactory::make("filesrc")
        .property("location", path)
        .build()
        .context("create filesrc")?;
    let decodebin = gst::ElementFactory::make("decodebin3")
        .build()
        .context("create decodebin3")?;
    let glupload = gst::ElementFactory::make("glupload")
        .build()
        .context("create glupload")?;
    let glcolorconvert = gst::ElementFactory::make("glcolorconvert")
        .build()
        .context("create glcolorconvert")?;

    let sink_caps =
        gst::Caps::from_str("video/x-raw(memory:GLMemory),format=RGBA,texture-target=2D")
            .context("parse appsink caps")?;
    let appsink = gst_app::AppSink::builder()
        .name("sink")
        .caps(&sink_caps)
        .sync(true)
        .max_buffers(1)
        .build();

    pipeline
        .add_many([
            &filesrc,
            &decodebin,
            &glupload,
            &glcolorconvert,
            appsink.upcast_ref(),
        ])
        .context("add elements to pipeline")?;

    gst::Element::link_many([&filesrc, &decodebin]).context("link filesrc → decodebin3")?;
    gst::Element::link_many([&glupload, &glcolorconvert, appsink.upcast_ref()])
        .context("link glupload → glcolorconvert → appsink")?;

    // decodebin3 exposes source pads as streams are discovered. glupload's sink
    // pad only accepts video caps, so non-video pads (audio, subtitles) fail to
    // link and are silently ignored.
    let glupload_weak = glupload.downgrade();
    decodebin.connect_pad_added(move |_, src_pad| {
        let Some(glupload) = glupload_weak.upgrade() else {
            return;
        };
        let Some(sink_pad) = glupload.static_pad("sink") else {
            return;
        };
        if sink_pad.is_linked() {
            return;
        }
        if let Err(err) = src_pad.link(&sink_pad) {
            log::warn!("link decodebin3 video pad → glupload: {err:?}");
        }
    });

    Ok(pipeline)
}

pub fn sample_to_frame(
    sample: gst::Sample,
    gl_context: &gst_gl::GLContext,
) -> anyhow::Result<Frame> {
    let buffer = sample.buffer_owned().context("sample has no buffer")?;
    let caps = sample.caps().context("sample has no caps")?;
    let info = gst_video::VideoInfo::from_caps(caps).context("VideoInfo from caps")?;

    if let Some(sync_meta) = buffer.meta::<gst_gl::GLSyncMeta>() {
        sync_meta.wait(gl_context);
    }

    let gl_frame = gst_gl::GLVideoFrame::from_buffer_readable(buffer, &info)
        .map_err(|_| anyhow!("GLVideoFrame::from_buffer_readable failed"))?;

    let texture_id = gl_frame
        .texture_id(0)
        .context("GLVideoFrame texture id missing")?;

    Ok(Frame {
        texture_id,
        width: info.width(),
        height: info.height(),
    })
}

pub fn wrap_gl(
    egl_display: usize,
    egl_context: usize,
) -> anyhow::Result<(gst_gl::GLDisplay, gst_gl::GLContext)> {
    gst::init().context("gst::init")?;

    let gl_display = unsafe { gst_gl_egl::GLDisplayEGL::with_egl_display(egl_display) }
        .context("wrap EGL display for GStreamer")?;

    let gl_display = gl_display.upcast::<gst_gl::GLDisplay>();

    let gl_context = unsafe {
        gst_gl::GLContext::new_wrapped(
            &gl_display,
            egl_context,
            gst_gl::GLPlatform::EGL,
            gst_gl::GLAPI::GLES2,
        )
    }
    .context("wrap EGL context for GStreamer")?;

    gl_context
        .activate(true)
        .map_err(|_| anyhow!("GLContext::activate failed"))?;

    gl_context
        .fill_info()
        .map_err(|e| anyhow!("GLContext::fill_info: {e}"))?;

    Ok((gl_display, gl_context))
}