use std::{
fmt,
os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd},
path::{Path, PathBuf},
str::FromStr,
};
use anyhow::{Context, Result, bail, ensure};
use gst::prelude::*;
use crate::portal::Stream;
const AUDIO_SAMPLE_RATE: i32 = 48_000;
const DEFAULT_H264_BITRATE: u32 = 12_000_000;
const DEFAULT_VP8_BITRATE: u32 = 8_000_000;
const RAW_VIDEO_QUEUE_MAX_BUFFERS: u32 = 2;
const RAW_AUDIO_QUEUE_MAX_TIME: u64 = 2_000_000_000;
const MUX_QUEUE_MAX_TIME: u64 = 3_000_000_000;
const MAX_VIDEO_PROCESSING_THREADS: u32 = 8;
const MAX_PORTAL_STREAMS: usize = 16;
const MAX_PORTAL_STREAM_DIMENSION: i32 = 32_768;
const MAX_PORTAL_CAPTURE_PIXELS: i64 = 268_435_456;
const NEON_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float luma(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
vec3 grade(vec3 color) {
color = pow(color, vec3(0.78));
return color * vec3(0.86, 0.94, 1.08);
}
void main() {
vec2 uv = v_texcoord;
vec2 px = vec2(1.0 / 960.0, 1.0 / 540.0);
vec3 color = texture2D(tex, uv).rgb;
float center = luma(color);
float left = luma(texture2D(tex, uv - vec2(px.x, 0.0)).rgb);
float right = luma(texture2D(tex, uv + vec2(px.x, 0.0)).rgb);
float top = luma(texture2D(tex, uv - vec2(0.0, px.y)).rgb);
float bottom = luma(texture2D(tex, uv + vec2(0.0, px.y)).rgb);
float diag_a = luma(texture2D(tex, uv + px).rgb) - luma(texture2D(tex, uv - px).rgb);
float diag_b = luma(texture2D(tex, uv + vec2(px.x, -px.y)).rgb)
- luma(texture2D(tex, uv + vec2(-px.x, px.y)).rgb);
vec2 sobel = vec2(right - left, bottom - top);
float edge = smoothstep(0.035, 0.20, length(sobel) + abs(diag_a + diag_b) * 0.32);
float halo = smoothstep(0.00, 0.18, abs(left + right + top + bottom - center * 4.0));
float circuit = smoothstep(0.975, 0.998, sin((uv.x + uv.y) * 128.0) * sin((uv.x - uv.y) * 91.0));
float scan = 0.88 + 0.12 * sin(uv.y * 1620.0 + edge * 2.4);
vec3 cyan = vec3(0.00, 0.95, 1.00) * edge;
vec3 violet = vec3(0.95, 0.05, 1.00) * edge * edge;
vec3 mint = vec3(0.00, 1.00, 0.58) * halo * 0.52;
vec3 lifted = grade(color);
vec3 final_color = mix(lifted * 0.70, lifted + cyan * 1.40 + violet * 1.12 + mint, clamp(edge * 1.7, 0.0, 1.0));
final_color += vec3(0.00, 0.70, 1.00) * circuit * edge * 0.22;
gl_FragColor = vec4(clamp(final_color * scan, 0.0, 1.0), 1.0);
}
"#;
const HEATMAP_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float luma(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec2 uv = v_texcoord;
vec3 color = texture2D(tex, uv).rgb;
float t = smoothstep(0.02, 0.98, luma(color));
vec3 deep = vec3(0.02, 0.02, 0.16);
vec3 blue = vec3(0.0, 0.38, 1.0);
vec3 gold = vec3(1.0, 0.74, 0.05);
vec3 white = vec3(1.0, 0.96, 0.82);
vec3 cold_to_mid = mix(deep, blue, smoothstep(0.00, 0.42, t));
vec3 mid_to_hot = mix(gold, white, smoothstep(0.58, 1.00, t));
vec3 heat = mix(cold_to_mid, mid_to_hot, smoothstep(0.34, 0.72, t));
float vignette = smoothstep(0.82, 0.18, distance(uv, vec2(0.5)));
gl_FragColor = vec4(clamp(heat * (0.68 + 0.44 * vignette), 0.0, 1.0), 1.0);
}
"#;
const GLITCH_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float hash(vec2 value) {
return fract(sin(dot(value, vec2(12.9898, 78.233))) * 43758.5453);
}
float luma(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec2 uv = v_texcoord;
vec2 coarse = floor(uv * vec2(22.0, 14.0));
vec2 fine = floor(uv * vec2(90.0, 54.0));
float band = sin(uv.y * 92.0) + 0.55 * sin(uv.y * 257.0) + 0.25 * sin(uv.y * 631.0);
float block = step(0.948, hash(coarse));
float tear = step(0.985, hash(vec2(floor(uv.y * 72.0), coarse.x)));
float grit = hash(fine) - 0.5;
float shift = band * 0.0026 + block * 0.020 + tear * (0.030 + grit * 0.010);
float r = texture2D(tex, uv + vec2(shift + 0.004, 0.0)).r;
float g = texture2D(tex, uv + vec2(shift * 0.25, 0.0)).g;
float b = texture2D(tex, uv - vec2(shift + 0.004, 0.0)).b;
vec3 color = vec3(r, g, b);
float scanline = step(0.86, fract(uv.y * 146.0 + grit * 0.35));
float data_line = smoothstep(0.996, 1.0, sin(uv.y * 480.0 + hash(coarse) * 6.28318));
vec3 invert = vec3(1.0) - color;
color = mix(color, color.brg, block * 0.30);
color = mix(color, invert, tear * 0.16);
color += vec3(0.11, -0.02, 0.18) * scanline;
color += vec3(0.00, 0.85, 1.00) * data_line * (0.10 + 0.16 * luma(color));
color += grit * 0.025;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const RIPPLE_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
void main() {
vec2 uv = v_texcoord;
vec2 center = vec2(0.5, 0.5);
vec2 delta = uv - center;
float radius = length(delta);
vec2 direction = delta / max(radius, 0.0008);
float ring = sin(radius * 72.0);
float falloff = exp(-radius * 3.35);
float wave = ring * falloff;
vec2 refract_uv = clamp(uv + direction * wave * 0.014, vec2(0.001), vec2(0.999));
vec3 color = texture2D(tex, refract_uv).rgb;
float caustic = pow(max(0.0, wave), 2.0) * 0.42;
float trough = pow(max(0.0, -wave), 2.0) * 0.16;
color += vec3(0.06, 0.20, 0.28) * caustic;
color -= vec3(0.02, 0.04, 0.06) * trough;
color *= 0.94 + 0.06 * smoothstep(0.62, 0.0, radius);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const GLASS_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float hash(vec2 value) {
return fract(sin(dot(value, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
vec2 uv = v_texcoord;
vec2 cell = floor(uv * vec2(42.0, 24.0));
float n0 = hash(cell);
float n1 = hash(cell + vec2(7.0, 19.0));
vec2 local = fract(uv * vec2(42.0, 24.0)) - 0.5;
float facet = hash(cell + vec2(23.0, 5.0));
float angle = (facet - 0.5) * 1.570796;
mat2 rotate = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
vec2 facet_local = rotate * local;
vec2 shard = (vec2(n0, n1) - 0.5) * 0.020;
vec2 frost = vec2(
sin((uv.y + n0) * 108.0) + sin((uv.x + n1) * 37.0),
cos((uv.x + n1) * 96.0) + cos((uv.y + n0) * 31.0)
) * 0.0042;
vec2 lens = facet_local * (0.012 + 0.007 * n0);
float bevel = smoothstep(0.39, 0.50, abs(facet_local.x))
+ smoothstep(0.39, 0.50, abs(facet_local.y));
vec2 bend = shard + frost - lens + normalize(facet_local + 0.0001) * bevel * 0.004;
float r = texture2D(tex, clamp(uv + bend * 1.55, vec2(0.001), vec2(0.999))).r;
float g = texture2D(tex, clamp(uv + bend * 0.78, vec2(0.001), vec2(0.999))).g;
float b = texture2D(tex, clamp(uv - bend * 1.35, vec2(0.001), vec2(0.999))).b;
vec3 color = vec3(r, g, b);
float crack = 1.0 - smoothstep(0.0, 0.020, abs(abs(facet_local.x) - abs(facet_local.y)) - 0.012);
float highlight = pow(max(0.0, 1.0 - length(facet_local * vec2(1.5, 2.1))), 5.0);
color = mix(color, vec3(dot(color, vec3(0.299, 0.587, 0.114))), 0.08);
color += vec3(0.16, 0.24, 0.34) * clamp(bevel, 0.0, 1.0);
color += vec3(0.55, 0.82, 1.00) * crack * 0.16;
color += vec3(0.28, 0.38, 0.46) * highlight * 0.42;
color *= 0.96 + 0.06 * smoothstep(0.62, 0.0, distance(uv, vec2(0.5)));
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const CRT_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
void main() {
vec2 centered = v_texcoord * 2.0 - 1.0;
float r2 = dot(centered, centered);
vec2 uv = centered * (1.0 + 0.085 * r2) * 0.5 + 0.5;
if (uv.x < 0.0 || uv.y < 0.0 || uv.x > 1.0 || uv.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
float red = texture2D(tex, clamp(uv + vec2(0.0028, 0.0), vec2(0.001), vec2(0.999))).r;
float green = texture2D(tex, uv).g;
float blue = texture2D(tex, clamp(uv - vec2(0.0028, 0.0), vec2(0.001), vec2(0.999))).b;
vec3 color = vec3(red, green, blue);
float scan = 0.72 + 0.28 * sin(uv.y * 1280.0);
float mask = 0.88 + 0.12 * sin(uv.x * 1880.0);
float vignette = smoothstep(1.18, 0.25, r2);
color *= scan * mask * vignette;
color += vec3(0.015, 0.055, 0.035);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const HOLO_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float luma(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec2 uv = v_texcoord;
vec2 px = vec2(1.0 / 960.0, 1.0 / 540.0);
vec3 base = texture2D(tex, uv).rgb;
float gx = luma(texture2D(tex, uv + vec2(px.x, 0.0)).rgb)
- luma(texture2D(tex, uv - vec2(px.x, 0.0)).rgb);
float gy = luma(texture2D(tex, uv + vec2(0.0, px.y)).rgb)
- luma(texture2D(tex, uv - vec2(0.0, px.y)).rgb);
float edge = smoothstep(0.025, 0.18, length(vec2(gx, gy)));
float interference = 0.5 + 0.5 * sin((uv.x + uv.y) * 210.0 + sin(uv.y * 32.0) * 2.0);
vec3 prism = vec3(
texture2D(tex, clamp(uv + vec2(0.0035, 0.0), vec2(0.001), vec2(0.999))).r,
texture2D(tex, uv).g,
texture2D(tex, clamp(uv - vec2(0.0035, 0.0), vec2(0.001), vec2(0.999))).b
);
vec3 holo = mix(vec3(0.0, 0.75, 1.0), vec3(1.0, 0.15, 0.92), interference);
vec3 color = mix(base * 0.54 + prism * 0.34, prism + holo * edge * 1.35, 0.58);
color += holo * pow(interference, 6.0) * 0.18;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const VAPOR_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
void main() {
vec2 uv = v_texcoord;
vec2 centered = uv - vec2(0.5);
float radius = length(centered);
float angle = atan(centered.y, centered.x);
float swirl = sin(radius * 34.0 + angle * 5.0) * exp(-radius * 2.1);
vec2 flow = vec2(cos(angle + swirl), sin(angle + swirl)) * swirl * 0.018;
vec2 smoke = vec2(
sin(uv.y * 28.0 + uv.x * 8.0),
cos(uv.x * 24.0 - uv.y * 7.0)
) * 0.006;
vec3 color = texture2D(tex, clamp(uv + flow + smoke, vec2(0.001), vec2(0.999))).rgb;
float glow = smoothstep(0.74, 0.12, radius);
vec3 vapor = mix(vec3(0.06, 0.02, 0.16), vec3(0.20, 0.95, 1.0), glow);
color = mix(color * vec3(0.78, 0.74, 1.0), color + vapor * 0.42, glow);
color += vec3(0.45, 0.0, 0.72) * pow(max(0.0, swirl), 2.0);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const FRACTURE_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float hash(vec2 value) {
return fract(sin(dot(value, vec2(41.7, 289.3))) * 53127.9123);
}
void main() {
vec2 uv = v_texcoord;
vec2 grid = vec2(18.0, 11.0);
vec2 cell = floor(uv * grid);
vec2 local = fract(uv * grid) - 0.5;
float h0 = hash(cell);
float h1 = hash(cell + vec2(3.0, 17.0));
vec2 fracture = (vec2(h0, h1) - 0.5) * 0.030;
float crack = smoothstep(0.44, 0.50, abs(local.x + local.y * sign(h0 - 0.5)))
+ smoothstep(0.46, 0.50, abs(local.x - local.y * sign(h1 - 0.5)));
float r = texture2D(tex, clamp(uv + fracture * 1.15, vec2(0.001), vec2(0.999))).r;
float g = texture2D(tex, clamp(uv + fracture * 0.45, vec2(0.001), vec2(0.999))).g;
float b = texture2D(tex, clamp(uv - fracture * 0.95, vec2(0.001), vec2(0.999))).b;
vec3 color = vec3(r, g, b);
color += vec3(0.40, 0.82, 1.0) * clamp(crack, 0.0, 1.0) * 0.32;
color *= 0.92 + 0.12 * h0;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const PRISM_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float luma(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
vec2 uv = v_texcoord;
vec2 centered = uv - vec2(0.5);
float radius = length(centered);
vec2 direction = centered / max(radius, 0.0008);
float angle = atan(centered.y, centered.x);
float spokes = cos(angle * 6.0);
float chroma = 0.004 + radius * radius * 0.024;
float ripple = sin(radius * 52.0 + angle * 5.0) * 0.0028;
vec2 tangent = vec2(-direction.y, direction.x);
vec2 bend = direction * (chroma + ripple) + tangent * spokes * radius * 0.0035;
float r = texture2D(tex, clamp(uv + bend * 1.35, vec2(0.001), vec2(0.999))).r;
float g = texture2D(tex, clamp(uv - bend * 0.12, vec2(0.001), vec2(0.999))).g;
float b = texture2D(tex, clamp(uv - bend * 1.20, vec2(0.001), vec2(0.999))).b;
vec3 color = vec3(r, g, b);
vec3 spectrum = 0.5 + 0.5 * cos(6.28318 * (uv.x + uv.y + spokes * 0.08) + vec3(0.0, 2.1, 4.2));
float facet = smoothstep(0.90, 1.0, abs(spokes));
float fresnel = smoothstep(0.08, 0.78, radius);
color = mix(vec3(luma(color)), color, 0.82);
color += spectrum * fresnel * 0.30 + spectrum * facet * 0.14;
color *= 0.94 + 0.10 * smoothstep(0.72, 0.0, radius);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const AURORA_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
float ridge(float value) {
return 1.0 - abs(value * 2.0 - 1.0);
}
void main() {
vec2 uv = v_texcoord;
float curtain = sin(uv.x * 15.0 + sin(uv.y * 7.0) * 2.4)
+ 0.55 * sin((uv.x + uv.y * 0.45) * 28.0)
+ 0.28 * sin((uv.x - uv.y) * 52.0)
+ 0.15 * sin((uv.x * 2.0 + uv.y) * 93.0);
float strands = ridge(fract(curtain * 0.19 + uv.x * 2.2));
float veil = smoothstep(-0.35, 1.25, curtain) * smoothstep(1.05, 0.05, uv.y);
veil += pow(strands, 5.0) * smoothstep(0.98, 0.12, uv.y) * 0.30;
vec2 flow = vec2(
sin(uv.y * 16.0 + curtain) * 0.012,
cos(uv.x * 11.0 - curtain) * 0.007
);
vec3 base = texture2D(tex, clamp(uv + flow, vec2(0.001), vec2(0.999))).rgb;
vec3 green = vec3(0.08, 1.00, 0.56);
vec3 violet = vec3(0.54, 0.12, 1.00);
vec3 cyan = vec3(0.08, 0.82, 1.00);
vec3 aurora = mix(green, violet, smoothstep(-0.1, 1.0, sin(curtain + uv.x * 4.0)));
aurora = mix(aurora, cyan, smoothstep(0.35, 1.0, veil));
vec3 color = mix(base * 0.72, base + aurora * 0.62, veil * 0.72);
color += aurora * pow(clamp(veil, 0.0, 1.0), 3.0) * 0.30;
color += vec3(0.58, 0.88, 1.0) * pow(strands, 8.0) * 0.12;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
const KALEIDO_FRAGMENT_SHADER: &str = r#"
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D tex;
void main() {
vec2 centered = v_texcoord - vec2(0.5);
float radius = length(centered);
float angle = atan(centered.y, centered.x);
float wedge = 0.785398;
float folded = mod(angle + wedge * 8.0, wedge * 2.0);
angle = abs(folded - wedge);
float rosette = sin(radius * 28.0) * 0.022 + cos(folded * 4.0) * radius * 0.015;
vec2 uv = vec2(cos(angle), sin(angle)) * (radius + rosette) + vec2(0.5);
uv = clamp(uv, vec2(0.001), vec2(0.999));
vec3 color = texture2D(tex, uv).rgb;
vec3 flip = texture2D(tex, vec2(1.0 - uv.x, uv.y)).rgb;
vec3 spin = texture2D(tex, clamp(vec2(uv.y, 1.0 - uv.x), vec2(0.001), vec2(0.999))).rgb;
float facet = smoothstep(0.02, 0.14, abs(sin(angle * 8.0 + radius * 10.0)));
float seam = 1.0 - smoothstep(0.0, 0.028, abs(sin(folded * 4.0)));
color = mix(mix(color, flip.brg, 0.32), spin.gbr, 0.18 + 0.18 * facet);
color += vec3(0.0, 0.75, 1.0) * pow(1.0 - facet, 2.0) * 0.16;
color += vec3(1.0, 0.18, 0.82) * seam * 0.10;
color *= smoothstep(0.78, 0.08, radius);
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
"#;
#[derive(Debug)]
pub struct PipelineOptions {
pub video_source: VideoSource,
pub temp_path: PathBuf,
pub output_format: OutputFormat,
pub fps: u32,
pub bitrate: u32,
pub codec: VideoCodec,
pub effect: VideoEffect,
pub audio: AudioMode,
pub area: Option<CaptureArea>,
pub scale: Option<OutputSize>,
}
#[derive(Debug)]
pub enum VideoSource {
Portal { fd: OwnedFd, streams: Vec<Stream> },
X11 { show_pointer: bool },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Mkv,
Mp4,
Gif,
}
impl OutputFormat {
pub fn extension(self) -> &'static str {
match self {
Self::Mkv => "mkv",
Self::Mp4 => "mp4",
Self::Gif => "gif",
}
}
pub fn muxer_element(self) -> Option<&'static str> {
match self {
Self::Mkv => Some("matroskamux"),
Self::Mp4 => Some("mp4mux"),
Self::Gif => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoCodec {
H264(H264Encoder),
Mjpeg,
Vp8,
Gif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoEffect {
None,
Neon,
Heatmap,
Glitch,
Ripple,
Glass,
Crt,
Holo,
Vapor,
Fracture,
Prism,
Aurora,
Kaleido,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum H264Encoder {
OpenH264,
Va,
Vaapi,
Nv,
}
impl H264Encoder {
pub fn element(self) -> &'static str {
match self {
Self::OpenH264 => "openh264enc",
Self::Va => "vah264enc",
Self::Vaapi => "vaapih264enc",
Self::Nv => "nvh264enc",
}
}
fn is_hardware(self) -> bool {
!matches!(self, Self::OpenH264)
}
}
impl VideoCodec {
pub fn required_elements(self) -> Vec<&'static str> {
match self {
Self::H264(encoder) => vec![encoder.element(), "h264parse", "capsfilter"],
Self::Mjpeg => vec!["jpegenc"],
Self::Vp8 => vec!["vp8enc"],
Self::Gif => vec!["gifenc"],
}
}
}
impl VideoEffect {
pub fn required_elements(self) -> &'static [&'static str] {
match self {
Self::None => &[],
Self::Neon
| Self::Heatmap
| Self::Glitch
| Self::Ripple
| Self::Glass
| Self::Crt
| Self::Holo
| Self::Vapor
| Self::Fracture
| Self::Prism
| Self::Aurora
| Self::Kaleido => &[
"glupload",
"glshader",
"gldownload",
"videoconvert",
"capsfilter",
],
}
}
fn fragment_shader(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Neon => Some(NEON_FRAGMENT_SHADER),
Self::Heatmap => Some(HEATMAP_FRAGMENT_SHADER),
Self::Glitch => Some(GLITCH_FRAGMENT_SHADER),
Self::Ripple => Some(RIPPLE_FRAGMENT_SHADER),
Self::Glass => Some(GLASS_FRAGMENT_SHADER),
Self::Crt => Some(CRT_FRAGMENT_SHADER),
Self::Holo => Some(HOLO_FRAGMENT_SHADER),
Self::Vapor => Some(VAPOR_FRAGMENT_SHADER),
Self::Fracture => Some(FRACTURE_FRAGMENT_SHADER),
Self::Prism => Some(PRISM_FRAGMENT_SHADER),
Self::Aurora => Some(AURORA_FRAGMENT_SHADER),
Self::Kaleido => Some(KALEIDO_FRAGMENT_SHADER),
}
}
fn name(self) -> &'static str {
match self {
Self::None => "none",
Self::Neon => "neon",
Self::Heatmap => "heatmap",
Self::Glitch => "glitch",
Self::Ripple => "ripple",
Self::Glass => "glass",
Self::Crt => "crt",
Self::Holo => "holo",
Self::Vapor => "vapor",
Self::Fracture => "fracture",
Self::Prism => "prism",
Self::Aurora => "aurora",
Self::Kaleido => "kaleido",
}
}
}
impl fmt::Display for VideoEffect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
pub fn hardware_h264_encoder() -> Option<H264Encoder> {
[H264Encoder::Va, H264Encoder::Vaapi, H264Encoder::Nv]
.into_iter()
.find(|encoder| gst::ElementFactory::find(encoder.element()).is_some())
}
pub fn aac_encoder_element() -> Option<&'static str> {
["fdkaacenc", "voaacenc", "avenc_aac"]
.into_iter()
.find(|name| gst::ElementFactory::find(name).is_some())
}
impl fmt::Display for VideoCodec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::H264(encoder) if encoder.is_hardware() => {
write!(f, "h264 hardware ({})", encoder.element())
}
Self::H264(encoder) => write!(f, "h264 ({})", encoder.element()),
Self::Mjpeg => f.write_str("mjpeg"),
Self::Vp8 => f.write_str("vp8"),
Self::Gif => f.write_str("gif"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CaptureArea {
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
}
impl CaptureArea {
pub fn new(x: i32, y: i32, width: i32, height: i32) -> Result<Self> {
ensure!(x >= 0, "selection x must be >= 0");
ensure!(y >= 0, "selection y must be >= 0");
ensure!(width > 0, "selection width must be > 0");
ensure!(height > 0, "selection height must be > 0");
ensure!(
x <= i32::MAX - width,
"selection right edge exceeds i32 range"
);
ensure!(
y <= i32::MAX - height,
"selection bottom edge exceeds i32 range"
);
Ok(Self {
x,
y,
width,
height,
})
}
pub fn right(self) -> i32 {
self.x + self.width
}
pub fn bottom(self) -> i32 {
self.y + self.height
}
}
impl FromStr for CaptureArea {
type Err = anyhow::Error;
fn from_str(raw: &str) -> Result<Self> {
if let Some(area) = parse_comma_area(raw)? {
return Ok(area);
}
if let Some(area) = parse_x11_geometry(raw)? {
return Ok(area);
}
bail!("expected X,Y,WIDTH,HEIGHT or WIDTHxHEIGHT+X+Y")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputSize {
pub width: i32,
pub height: i32,
}
impl OutputSize {
pub fn new(width: i32, height: i32) -> Result<Self> {
ensure!(width > 0, "output width must be > 0");
ensure!(height > 0, "output height must be > 0");
let width = round_to_even(width);
let height = round_to_even(height);
ensure!(
width > 0,
"output width must be at least 2 after even rounding"
);
ensure!(
height > 0,
"output height must be at least 2 after even rounding"
);
Ok(Self { width, height })
}
}
impl FromStr for OutputSize {
type Err = anyhow::Error;
fn from_str(raw: &str) -> Result<Self> {
let Some((width, height)) = raw.split_once('x') else {
bail!("expected WIDTHxHEIGHT")
};
Self::new(
width.parse().context("invalid output width")?,
height.parse().context("invalid output height")?,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioMode {
None,
Microphone,
System,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeviceClass {
Source,
Sink,
}
impl DeviceClass {
fn as_str(self) -> &'static str {
match self {
Self::Source => "Audio/Source",
Self::Sink => "Audio/Sink",
}
}
fn description(self) -> &'static str {
match self {
Self::Source => "microphone",
Self::Sink => "system audio",
}
}
}
pub fn preflight_audio(mode: AudioMode) -> Result<()> {
for (class, _) in audio_sources(mode) {
let _pulsesrc = make_pulsesrc(class, "ocula-audio-preflight-src")
.with_context(|| format!("failed to prepare {} capture", class.description()))?;
}
Ok(())
}
pub fn build(options: PipelineOptions) -> Result<gst::Pipeline> {
let pipeline = gst::Pipeline::builder()
.name("ocula-recording-pipeline")
.build();
let skip_video_convert = options.effect == VideoEffect::None
&& can_skip_video_convert(&options.video_source, options.codec);
let video_src = make_video_source(options.video_source, options.fps, options.area)?;
let video_queue = make_video_queue("ocula-video-queue")?;
let scale_elements = make_scale_elements(options.scale)?;
let effect_elements = make_video_effect_elements(options.effect)?;
let video_convert = (!skip_video_convert).then(make_video_convert).transpose()?;
let video_format_caps = make_video_format_caps(options.codec)?;
let encoder_elements = make_video_encoder(options.codec, options.fps, options.bitrate)?;
let encoded_video_queue = make_mux_queue("ocula-encoded-video-queue")?;
let muxer = options
.output_format
.muxer_element()
.map(make_muxer)
.transpose()?;
let filesink = gst::ElementFactory::make("filesink")
.name("ocula-filesink")
.property("location", path_to_str(&options.temp_path)?)
.property("sync", false)
.property("async", false)
.build()?;
pipeline.add(&video_src)?;
pipeline.add_many([&video_queue, &encoded_video_queue, &filesink])?;
if let Some(convert) = &video_convert {
pipeline.add(convert)?;
}
if let Some(muxer) = &muxer {
pipeline.add(muxer)?;
}
for element in &scale_elements {
pipeline.add(element)?;
}
for element in &effect_elements {
pipeline.add(element)?;
}
if let Some(capsfilter) = &video_format_caps {
pipeline.add(capsfilter)?;
}
for element in &encoder_elements {
pipeline.add(element)?;
}
let mut video_chain = vec![
video_src.clone().upcast::<gst::Element>(),
video_queue.clone(),
];
video_chain.extend(scale_elements.iter().cloned());
video_chain.extend(effect_elements.iter().cloned());
if let Some(convert) = video_convert {
video_chain.push(convert);
}
if let Some(capsfilter) = video_format_caps {
video_chain.push(capsfilter);
}
video_chain.extend(encoder_elements.iter().cloned());
video_chain.push(encoded_video_queue.clone());
link_chain(&video_chain)?;
if let Some(muxer) = &muxer {
encoded_video_queue.link_pads(None, muxer, Some("video_%u"))?;
muxer.link(&filesink)?;
} else {
ensure!(
options.audio == AudioMode::None,
"GIF output does not support audio"
);
encoded_video_queue.link(&filesink)?;
}
if options.audio != AudioMode::None {
let muxer = muxer
.as_ref()
.context("selected output format does not support audio muxing")?;
attach_audio(&pipeline, muxer, options.audio, options.output_format)?;
}
Ok(pipeline)
}
fn make_muxer(element: &'static str) -> Result<gst::Element> {
match element {
"matroskamux" => Ok(gst::ElementFactory::make(element)
.name("ocula-muxer")
.property("streamable", true)
.property("min-cluster-duration", 250_000_000_i64)
.property("max-cluster-duration", 1_000_000_000_i64)
.property("writing-app", "Ocula")
.build()?),
"mp4mux" => Ok(gst::ElementFactory::make(element)
.name("ocula-muxer")
.build()?),
_ => unreachable!("unsupported muxer element"),
}
}
fn make_video_source(
video_source: VideoSource,
fps: u32,
area: Option<CaptureArea>,
) -> Result<gst::Bin> {
match video_source {
VideoSource::Portal { fd, streams } => make_portal_video_src_bin(fd, &streams, fps, area),
VideoSource::X11 { show_pointer } => make_x11_video_src_bin(show_pointer, fps, area),
}
}
fn can_skip_video_convert(video_source: &VideoSource, codec: VideoCodec) -> bool {
matches!(video_source, VideoSource::X11 { .. }) && codec == VideoCodec::Mjpeg
}
fn make_portal_video_src_bin(
fd: OwnedFd,
streams: &[Stream],
fps: u32,
area: Option<CaptureArea>,
) -> Result<gst::Bin> {
validate_portal_streams(streams)?;
let bin = gst::Bin::builder()
.name("ocula-portal-video-src-bin")
.build();
let crop = area
.map(|area| make_portal_crop(area, streams))
.transpose()?;
let videorate = make_videorate("ocula-portal-videorate", fps)?;
let capsfilter = make_video_capsfilter(fps)?;
bin.add_many([&videorate, &capsfilter])?;
if let Some(crop) = &crop {
bin.add(crop)?;
crop.link(&videorate)?;
}
videorate.link(&capsfilter)?;
match streams {
[stream] => {
let pipewiresrc = make_pipewiresrc(&fd, stream)?;
let videoflip = make_videoflip()?;
bin.add_many([&pipewiresrc, &videoflip])?;
if let Some(crop) = &crop {
gst::Element::link_many([&pipewiresrc, &videoflip, crop])?;
} else {
gst::Element::link_many([&pipewiresrc, &videoflip, &videorate])?;
}
}
streams => {
let compositor = gst::ElementFactory::make("compositor")
.name("ocula-compositor")
.build()?;
bin.add(&compositor)?;
if let Some(crop) = &crop {
compositor.link(crop)?;
} else {
compositor.link(&videorate)?;
}
let min_x = streams
.iter()
.filter_map(|stream| stream.position().map(|(x, _)| x))
.min()
.unwrap_or(0);
let min_y = streams
.iter()
.filter_map(|stream| stream.position().map(|(_, y)| y))
.min()
.unwrap_or(0);
let mut next_x = 0;
for stream in streams {
let pipewiresrc = make_pipewiresrc(&fd, stream)?;
let videoflip = make_videoflip()?;
bin.add_many([&pipewiresrc, &videoflip])?;
pipewiresrc.link(&videoflip)?;
let sink_pad = compositor
.request_pad_simple("sink_%u")
.context("failed to request compositor sink pad")?;
if let Some((x, y)) = stream.position() {
sink_pad.set_property("xpos", compositor_pad_offset(x, min_x, "x")?);
sink_pad.set_property("ypos", compositor_pad_offset(y, min_y, "y")?);
} else {
sink_pad.set_property("xpos", next_x);
}
videoflip
.static_pad("src")
.context("videoflip has no src pad")?
.link(&sink_pad)?;
next_x = next_compositor_x(next_x, stream)?;
}
}
}
add_src_ghost_pad(&bin, &capsfilter)?;
Ok(bin)
}
pub fn validate_portal_streams(streams: &[Stream]) -> Result<()> {
ensure!(!streams.is_empty(), "portal returned no screen streams");
ensure!(
streams.len() <= MAX_PORTAL_STREAMS,
"portal returned {} streams, exceeding limit of {}",
streams.len(),
MAX_PORTAL_STREAMS
);
let mut total_pixels = 0_i64;
for stream in streams {
if let Some((width, height)) = stream.size() {
ensure!(width > 0, "portal stream width must be > 0");
ensure!(height > 0, "portal stream height must be > 0");
ensure!(
width <= MAX_PORTAL_STREAM_DIMENSION && height <= MAX_PORTAL_STREAM_DIMENSION,
"portal stream size {}x{} exceeds supported limit {}x{}",
width,
height,
MAX_PORTAL_STREAM_DIMENSION,
MAX_PORTAL_STREAM_DIMENSION
);
let pixels = i64::from(width)
.checked_mul(i64::from(height))
.context("portal stream pixel count exceeds i64 range")?;
total_pixels = total_pixels
.checked_add(pixels)
.context("portal stream pixel total exceeds i64 range")?;
}
if let Some((x, y)) = stream.position() {
ensure!(
(-MAX_PORTAL_STREAM_DIMENSION..=MAX_PORTAL_STREAM_DIMENSION).contains(&x)
&& (-MAX_PORTAL_STREAM_DIMENSION..=MAX_PORTAL_STREAM_DIMENSION).contains(&y),
"portal stream position {x},{y} exceeds supported coordinate limit"
);
}
}
if let Some((width, height)) = portal_stream_size(streams) {
ensure!(
width <= MAX_PORTAL_STREAM_DIMENSION && height <= MAX_PORTAL_STREAM_DIMENSION,
"portal composited size {}x{} exceeds supported limit {}x{}",
width,
height,
MAX_PORTAL_STREAM_DIMENSION,
MAX_PORTAL_STREAM_DIMENSION
);
}
ensure!(
total_pixels <= MAX_PORTAL_CAPTURE_PIXELS,
"portal stream total pixel count exceeds supported limit"
);
Ok(())
}
fn make_x11_video_src_bin(
show_pointer: bool,
fps: u32,
area: Option<CaptureArea>,
) -> Result<gst::Bin> {
let bin = gst::Bin::builder().name("ocula-x11-video-src-bin").build();
let ximagesrc = gst::ElementFactory::make("ximagesrc")
.name("ocula-ximagesrc")
.property("show-pointer", show_pointer)
.property("do-timestamp", true)
.property("use-damage", false)
.build()?;
if let Some(area) = area {
ximagesrc.set_property("startx", u32::try_from(area.x)?);
ximagesrc.set_property("starty", u32::try_from(area.y)?);
ximagesrc.set_property("endx", u32::try_from(area.right() - 1)?);
ximagesrc.set_property("endy", u32::try_from(area.bottom() - 1)?);
}
let videorate = make_videorate("ocula-x11-videorate", fps)?;
let capsfilter = make_video_capsfilter(fps)?;
bin.add_many([&ximagesrc, &videorate, &capsfilter])?;
gst::Element::link_many([&ximagesrc, &videorate, &capsfilter])?;
add_src_ghost_pad(&bin, &capsfilter)?;
Ok(bin)
}
fn make_pipewiresrc(fd: &OwnedFd, stream: &Stream) -> Result<gst::Element> {
let src = gst::ElementFactory::make("pipewiresrc")
.name(format!("ocula-pipewiresrc-{}", stream.node_id()))
.property("path", stream.node_id().to_string())
.property("do-timestamp", true)
.property("provide-clock", false)
.property("keepalive-time", 1000_i32)
.property("resend-last", true)
.property_from_str("on-disconnect", "eos")
.build()?;
let stream_fd = duplicate_fd(fd)?;
src.set_property("fd", stream_fd.into_raw_fd());
Ok(src)
}
fn duplicate_fd(fd: &OwnedFd) -> Result<OwnedFd> {
let raw_fd = unsafe { libc::dup(fd.as_raw_fd()) };
if raw_fd < 0 {
return Err(std::io::Error::last_os_error()).context("failed to duplicate PipeWire FD");
}
Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) })
}
fn make_videoflip() -> Result<gst::Element> {
Ok(gst::ElementFactory::make("videoflip")
.property_from_str("video-direction", "auto")
.build()?)
}
fn make_video_capsfilter(fps: u32) -> Result<gst::Element> {
let fps = i32::try_from(fps).context("fps does not fit in i32")?;
Ok(gst::ElementFactory::make("capsfilter")
.name("ocula-video-framerate-caps")
.property(
"caps",
gst::Caps::builder("video/x-raw")
.field("framerate", gst::Fraction::new(fps, 1))
.build(),
)
.build()?)
}
fn make_videorate(name: &str, fps: u32) -> Result<gst::Element> {
let fps = i32::try_from(fps).context("fps does not fit in i32")?;
Ok(gst::ElementFactory::make("videorate")
.name(name)
.property("skip-to-first", true)
.property("drop-only", true)
.property("max-rate", fps)
.property("silent", true)
.build()?)
}
fn make_scale_elements(scale: Option<OutputSize>) -> Result<Vec<gst::Element>> {
let Some(scale) = scale else {
return Ok(Vec::new());
};
let videoscale = gst::ElementFactory::make("videoscale")
.name("ocula-video-scale")
.property("add-borders", true)
.property("n-threads", video_processing_threads())
.build()?;
let capsfilter = gst::ElementFactory::make("capsfilter")
.name("ocula-video-scale-caps")
.property(
"caps",
gst::Caps::builder("video/x-raw")
.field("width", scale.width)
.field("height", scale.height)
.field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
.build(),
)
.build()?;
Ok(vec![videoscale, capsfilter])
}
fn make_video_effect_elements(effect: VideoEffect) -> Result<Vec<gst::Element>> {
let Some(fragment) = effect.fragment_shader() else {
return Ok(Vec::new());
};
let input_convert = make_video_convert_named("ocula-effect-input-convert")?;
let input_caps = make_rgba_capsfilter("ocula-effect-input-rgba-caps")?;
let upload = gst::ElementFactory::make("glupload")
.name("ocula-effect-gl-upload")
.build()?;
let shader = gst::ElementFactory::make("glshader")
.name(format!("ocula-effect-{}-shader", effect.name()))
.property("fragment", fragment)
.build()?;
let download = gst::ElementFactory::make("gldownload")
.name("ocula-effect-gl-download")
.build()?;
let output_caps = make_rgba_capsfilter("ocula-effect-output-rgba-caps")?;
Ok(vec![
input_convert,
input_caps,
upload,
shader,
download,
output_caps,
])
}
fn make_rgba_capsfilter(name: &str) -> Result<gst::Element> {
Ok(gst::ElementFactory::make("capsfilter")
.name(name)
.property(
"caps",
gst::Caps::builder("video/x-raw")
.field("format", "RGBA")
.build(),
)
.build()?)
}
fn make_video_convert() -> Result<gst::Element> {
make_video_convert_named("ocula-video-convert")
}
fn make_video_convert_named(name: &str) -> Result<gst::Element> {
Ok(gst::ElementFactory::make("videoconvert")
.name(name)
.property("n-threads", video_processing_threads())
.property_from_str("dither", "none")
.build()?)
}
fn make_video_format_caps(codec: VideoCodec) -> Result<Option<gst::Element>> {
let format = match codec {
VideoCodec::H264(H264Encoder::OpenH264) | VideoCodec::Vp8 => Some("I420"),
VideoCodec::H264(_) | VideoCodec::Mjpeg | VideoCodec::Gif => None,
};
let Some(format) = format else {
return Ok(None);
};
Ok(Some(
gst::ElementFactory::make("capsfilter")
.name("ocula-video-format-caps")
.property(
"caps",
gst::Caps::builder("video/x-raw")
.field("format", format)
.build(),
)
.build()?,
))
}
fn video_processing_threads() -> u32 {
std::thread::available_parallelism()
.map(|threads| {
threads
.get()
.clamp(1, MAX_VIDEO_PROCESSING_THREADS as usize) as u32
})
.unwrap_or(1)
}
fn make_portal_crop(area: CaptureArea, streams: &[Stream]) -> Result<gst::Element> {
let (origin_x, origin_y, source_width, source_height) = validate_portal_area(area, streams)?;
let source_right = portal_stream_edge(origin_x, source_width, "right")?;
let source_bottom = portal_stream_edge(origin_y, source_height, "bottom")?;
let left = portal_crop_margin(area.x, origin_x, "left")?;
let top = portal_crop_margin(area.y, origin_y, "top")?;
let right = portal_crop_margin(source_right, area.right(), "right")?;
let bottom = portal_crop_margin(source_bottom, area.bottom(), "bottom")?;
Ok(gst::ElementFactory::make("videocrop")
.name("ocula-selection-crop")
.property("left", left)
.property("top", top)
.property("right", right)
.property("bottom", bottom)
.build()?)
}
pub fn validate_portal_area(area: CaptureArea, streams: &[Stream]) -> Result<(i32, i32, i32, i32)> {
let (origin_x, origin_y, source_width, source_height) = stream_bounds(streams)
.context("portal did not return stream geometry, so --area cannot be applied")?;
let source_right = portal_stream_edge(origin_x, source_width, "right")?;
let source_bottom = portal_stream_edge(origin_y, source_height, "bottom")?;
ensure!(
area.x >= origin_x && area.y >= origin_y,
"selection starts outside the captured portal stream"
);
ensure!(
area.right() <= source_right && area.bottom() <= source_bottom,
"selection extends outside the captured portal stream"
);
Ok((origin_x, origin_y, source_width, source_height))
}
pub fn scale_area_to_portal_streams(
area: CaptureArea,
screen_width: i32,
screen_height: i32,
streams: &[Stream],
) -> Result<CaptureArea> {
ensure_portal_area_scaling_is_unambiguous(streams)?;
ensure!(screen_width > 0, "selector screen width must be > 0");
ensure!(screen_height > 0, "selector screen height must be > 0");
ensure!(
area.right() <= screen_width && area.bottom() <= screen_height,
"selection extends outside the selector screen"
);
let (origin_x, origin_y, source_width, source_height) = stream_bounds(streams)
.context("portal did not return stream geometry, so selected area cannot be scaled")?;
let x = scaled_coordinate(origin_x, area.x, source_width, screen_width, "x")?;
let y = scaled_coordinate(origin_y, area.y, source_height, screen_height, "y")?;
let right = scaled_coordinate(origin_x, area.right(), source_width, screen_width, "right")?;
let bottom = scaled_coordinate(
origin_y,
area.bottom(),
source_height,
screen_height,
"bottom",
)?;
CaptureArea::new(
x,
y,
right
.checked_sub(x)
.context("scaled selection width is invalid")?,
bottom
.checked_sub(y)
.context("scaled selection height is invalid")?,
)
}
fn ensure_portal_area_scaling_is_unambiguous(streams: &[Stream]) -> Result<()> {
ensure!(
streams.len() <= 1,
"selected area cannot be safely scaled across multiple portal streams; select a single monitor in the portal chooser or use --backend x11 for interactive area selection"
);
Ok(())
}
pub fn portal_stream_size(streams: &[Stream]) -> Option<(i32, i32)> {
let (_, _, width, height) = stream_bounds(streams)?;
Some((width, height))
}
fn next_compositor_x(current: i32, stream: &Stream) -> Result<i32> {
let width = match stream.size() {
Some((width, _)) => {
ensure!(width > 0, "portal stream width must be > 0");
width
}
None => 1920,
};
current
.checked_add(width)
.context("portal stream layout exceeds i32 range")
}
fn compositor_pad_offset(value: i32, origin: i32, label: &str) -> Result<i32> {
value
.checked_sub(origin)
.with_context(|| format!("portal stream {label} offset exceeds i32 range"))
}
fn portal_stream_edge(origin: i32, size: i32, label: &str) -> Result<i32> {
origin
.checked_add(size)
.with_context(|| format!("portal stream {label} edge exceeds i32 range"))
}
fn portal_crop_margin(edge: i32, from: i32, label: &str) -> Result<i32> {
let margin = edge
.checked_sub(from)
.with_context(|| format!("portal crop {label} margin exceeds i32 range"))?;
ensure!(margin >= 0, "portal crop {label} margin is negative");
Ok(round_to_even(margin))
}
fn scale_coordinate(value: i32, source: i32, screen: i32) -> i32 {
((f64::from(value) * f64::from(source)) / f64::from(screen)).round() as i32
}
fn scaled_coordinate(
origin: i32,
value: i32,
source: i32,
screen: i32,
label: &str,
) -> Result<i32> {
origin
.checked_add(scale_coordinate(value, source, screen))
.with_context(|| format!("scaled selection {label} exceeds i32 range"))
}
fn stream_bounds(streams: &[Stream]) -> Option<(i32, i32, i32, i32)> {
if streams.is_empty() {
return None;
}
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
let mut next_x = 0_i32;
for stream in streams {
let (width, height) = stream.size()?;
if width <= 0 || height <= 0 {
return None;
}
let (x, y) = stream.position().unwrap_or((next_x, 0));
next_x = next_x.checked_add(width)?;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x.checked_add(width)?);
max_y = max_y.max(y.checked_add(height)?);
}
Some((
min_x,
min_y,
max_x.checked_sub(min_x)?,
max_y.checked_sub(min_y)?,
))
}
fn make_video_encoder(codec: VideoCodec, fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
match codec {
VideoCodec::H264(encoder) => make_h264_encoder(encoder, fps, bitrate),
VideoCodec::Mjpeg => make_mjpeg_encoder(),
VideoCodec::Vp8 => make_vp8_encoder(fps, bitrate),
VideoCodec::Gif => make_gif_encoder(),
}
}
fn make_h264_encoder(encoder: H264Encoder, fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
if encoder.is_hardware() {
return make_hardware_h264_encoder(encoder);
}
let threads = std::thread::available_parallelism()
.map(|threads| threads.get().clamp(1, 16) as u32)
.unwrap_or(4);
let bitrate = bitrate_or_default(bitrate, DEFAULT_H264_BITRATE);
let encoder = gst::ElementFactory::make("openh264enc")
.name("ocula-openh264enc")
.property("bitrate", bitrate)
.property("max-bitrate", bitrate)
.property("gop-size", fps.saturating_mul(2).max(1))
.property("multi-thread", threads)
.property("enable-frame-skip", true)
.property_from_str("complexity", "low")
.property_from_str("usage-type", "screen")
.property_from_str("rate-control", "bitrate")
.build()?;
let parser = gst::ElementFactory::make("h264parse")
.name("ocula-h264parse")
.property("config-interval", -1_i32)
.build()?;
let caps = gst::ElementFactory::make("capsfilter")
.name("ocula-h264-caps")
.property(
"caps",
gst::Caps::builder("video/x-h264")
.field("stream-format", "avc")
.field("alignment", "au")
.build(),
)
.build()?;
Ok(vec![encoder, parser, caps])
}
fn make_hardware_h264_encoder(encoder: H264Encoder) -> Result<Vec<gst::Element>> {
let encoder = gst::ElementFactory::make(encoder.element())
.name("ocula-hardware-h264enc")
.build()?;
let parser = gst::ElementFactory::make("h264parse")
.name("ocula-h264parse")
.property("config-interval", -1_i32)
.build()?;
let caps = gst::ElementFactory::make("capsfilter")
.name("ocula-h264-caps")
.property(
"caps",
gst::Caps::builder("video/x-h264")
.field("stream-format", "avc")
.field("alignment", "au")
.build(),
)
.build()?;
Ok(vec![encoder, parser, caps])
}
fn make_mjpeg_encoder() -> Result<Vec<gst::Element>> {
Ok(vec![
gst::ElementFactory::make("jpegenc")
.name("ocula-jpegenc")
.property("quality", 85_i32)
.build()?,
])
}
fn make_vp8_encoder(fps: u32, bitrate: u32) -> Result<Vec<gst::Element>> {
let keyframe_distance = i32::try_from(fps.saturating_mul(2)).unwrap_or(240);
let threads = std::thread::available_parallelism()
.map(|threads| threads.get().clamp(1, 16) as i32)
.unwrap_or(4);
let target_bitrate = i32::try_from(bitrate_or_default(bitrate, DEFAULT_VP8_BITRATE))
.context("VP8 bitrate does not fit in i32")?;
Ok(vec![
gst::ElementFactory::make("vp8enc")
.name("ocula-vp8enc")
.property("deadline", 1_i64)
.property("cpu-used", 8_i32)
.property("threads", threads)
.property("lag-in-frames", 0_i32)
.property("static-threshold", 100_i32)
.property("keyframe-max-dist", keyframe_distance)
.property("target-bitrate", target_bitrate)
.build()?,
])
}
fn make_gif_encoder() -> Result<Vec<gst::Element>> {
Ok(vec![
gst::ElementFactory::make("gifenc")
.name("ocula-gifenc")
.build()?,
])
}
fn bitrate_or_default(bitrate: u32, default: u32) -> u32 {
if bitrate == 0 { default } else { bitrate }
}
fn attach_audio(
pipeline: &gst::Pipeline,
muxer: &gst::Element,
mode: AudioMode,
output_format: OutputFormat,
) -> Result<()> {
let audio_src = make_audio_src_bin(mode)?;
let audio_queue = make_audio_queue("ocula-audio-queue")?;
let audio_convert = gst::ElementFactory::make("audioconvert")
.name("ocula-audio-convert")
.build()?;
let audio_resample = gst::ElementFactory::make("audioresample")
.name("ocula-audio-resample")
.build()?;
let audio_encoder = make_audio_encoder(output_format)?;
let encoded_audio_queue = make_mux_queue("ocula-encoded-audio-queue")?;
pipeline.add(&audio_src)?;
pipeline.add_many([
&audio_queue,
&audio_convert,
&audio_resample,
&audio_encoder,
&encoded_audio_queue,
])?;
gst::Element::link_many([
audio_src.upcast_ref(),
&audio_queue,
&audio_convert,
&audio_resample,
&audio_encoder,
&encoded_audio_queue,
])?;
encoded_audio_queue.link_pads(None, muxer, Some("audio_%u"))?;
Ok(())
}
fn make_audio_encoder(output_format: OutputFormat) -> Result<gst::Element> {
match output_format {
OutputFormat::Mkv => Ok(gst::ElementFactory::make("opusenc")
.name("ocula-opusenc")
.property("bitrate", 128_000_i32)
.build()?),
OutputFormat::Mp4 => {
let encoder = aac_encoder_element()
.context("MP4 audio requires an AAC encoder such as fdkaacenc")?;
let builder = gst::ElementFactory::make(encoder).name("ocula-aacenc");
if encoder == "fdkaacenc" {
Ok(builder.property("bitrate", 128_000_i32).build()?)
} else {
Ok(builder.build()?)
}
}
OutputFormat::Gif => bail!("GIF output does not support audio"),
}
}
fn make_audio_src_bin(mode: AudioMode) -> Result<gst::Bin> {
let bin = gst::Bin::builder().name("ocula-audio-src-bin").build();
let mixer = gst::ElementFactory::make("audiomixer")
.name("ocula-audiomixer")
.property("latency", gst::ClockTime::from_seconds(2))
.build()?;
let capsfilter = gst::ElementFactory::make("capsfilter")
.name("ocula-audio-caps")
.property(
"caps",
gst::Caps::builder("audio/x-raw")
.field("rate", AUDIO_SAMPLE_RATE)
.field("channels", 2_i32)
.build(),
)
.build()?;
bin.add_many([&mixer, &capsfilter])?;
mixer.link(&capsfilter)?;
for (class, name) in audio_sources(mode) {
let pulsesrc = make_pulsesrc(class, name)?;
let queue = make_audio_queue(&format!("{name}-queue"))?;
let rate = gst::ElementFactory::make("audiorate")
.name(format!("{name}-audiorate"))
.property("skip-to-first", true)
.build()?;
let convert = gst::ElementFactory::make("audioconvert")
.name(format!("{name}-convert"))
.build()?;
let resample = gst::ElementFactory::make("audioresample")
.name(format!("{name}-resample"))
.build()?;
bin.add_many([&pulsesrc, &queue, &rate, &convert, &resample])?;
gst::Element::link_many([&pulsesrc, &queue, &rate, &convert, &resample])?;
resample.link_pads(None, &mixer, Some("sink_%u"))?;
}
add_src_ghost_pad(&bin, &capsfilter)?;
Ok(bin)
}
fn audio_sources(mode: AudioMode) -> Vec<(DeviceClass, &'static str)> {
match mode {
AudioMode::None => Vec::new(),
AudioMode::Microphone => vec![(DeviceClass::Source, "ocula-microphone-src")],
AudioMode::System => vec![(DeviceClass::Sink, "ocula-system-audio-src")],
AudioMode::Both => vec![
(DeviceClass::Sink, "ocula-system-audio-src"),
(DeviceClass::Source, "ocula-microphone-src"),
],
}
}
fn make_pulsesrc(class: DeviceClass, element_name: &str) -> Result<gst::Element> {
let device = find_default_device(class)?;
let pulsesrc = gst::ElementFactory::make("pulsesrc")
.name(element_name)
.property("provide-clock", false)
.property("do-timestamp", true)
.build()?;
match class {
DeviceClass::Sink => {
let pulsesink = device.create_element(None)?;
let device_name = pulsesink
.property::<Option<String>>("device")
.context("default audio sink has no device name")?;
ensure!(!device_name.is_empty(), "default audio sink name is empty");
pulsesrc.set_property("device", format!("{device_name}.monitor"));
}
DeviceClass::Source => {
device.reconfigure_element(&pulsesrc)?;
let device_name = pulsesrc
.property::<Option<String>>("device")
.context("default microphone has no device name")?;
ensure!(!device_name.is_empty(), "default microphone name is empty");
}
}
Ok(pulsesrc)
}
fn find_default_device(class: DeviceClass) -> Result<gst::Device> {
let provider = gst::DeviceProviderFactory::by_name("pulsedeviceprovider")
.context("failed to find PulseAudio device provider")?;
provider.start().with_context(|| {
format!(
"failed to start PulseAudio device provider for {} capture",
class.description()
)
})?;
let devices = provider.devices();
provider.stop();
for device in devices {
if validate_device(&device, class).is_ok() {
return Ok(device);
}
}
bail!(
"failed to find default {} audio device",
class.description()
)
}
fn validate_device(device: &gst::Device, class: DeviceClass) -> Result<()> {
ensure!(
device.has_classes(class.as_str()),
"unexpected device class `{}`",
device.device_class()
);
let is_default = device
.properties()
.context("audio device has no properties")?
.get::<bool>("is-default")
.context("audio device has no is-default property")?;
ensure!(is_default, "not the default audio device");
Ok(())
}
fn make_video_queue(name: &str) -> Result<gst::Element> {
Ok(gst::ElementFactory::make("queue")
.name(name)
.property("max-size-buffers", RAW_VIDEO_QUEUE_MAX_BUFFERS)
.property("max-size-bytes", 0_u32)
.property("max-size-time", 0_u64)
.property("silent", true)
.property_from_str("leaky", "downstream")
.build()?)
}
fn make_audio_queue(name: &str) -> Result<gst::Element> {
Ok(gst::ElementFactory::make("queue")
.name(name)
.property("max-size-buffers", 0_u32)
.property("max-size-bytes", 0_u32)
.property("max-size-time", RAW_AUDIO_QUEUE_MAX_TIME)
.property("silent", true)
.build()?)
}
fn make_mux_queue(name: &str) -> Result<gst::Element> {
Ok(gst::ElementFactory::make("queue")
.name(name)
.property("max-size-buffers", 0_u32)
.property("max-size-bytes", 0_u32)
.property("max-size-time", MUX_QUEUE_MAX_TIME)
.build()?)
}
fn link_chain(elements: &[gst::Element]) -> Result<()> {
for pair in elements.windows(2) {
pair[0].link(&pair[1]).with_context(|| {
format!(
"failed to link `{}` to `{}`",
pair[0].name(),
pair[1].name()
)
})?;
}
Ok(())
}
fn add_src_ghost_pad(bin: &gst::Bin, element: &gst::Element) -> Result<()> {
let src_pad = element
.static_pad("src")
.context("element has no src pad")?;
bin.add_pad(&gst::GhostPad::with_target(&src_pad)?)?;
Ok(())
}
fn path_to_str(path: &Path) -> Result<&str> {
path.to_str()
.with_context(|| format!("path `{}` is not valid UTF-8", path.display()))
}
fn round_to_even(number: i32) -> i32 {
number / 2 * 2
}
fn parse_comma_area(raw: &str) -> Result<Option<CaptureArea>> {
let parts = raw.split(',').collect::<Vec<_>>();
if parts.len() != 4 {
return Ok(None);
}
let x = parts[0].parse().context("invalid selection x")?;
let y = parts[1].parse().context("invalid selection y")?;
let width = parts[2].parse().context("invalid selection width")?;
let height = parts[3].parse().context("invalid selection height")?;
CaptureArea::new(x, y, width, height).map(Some)
}
fn parse_x11_geometry(raw: &str) -> Result<Option<CaptureArea>> {
let Some((size, position)) = raw.split_once('+') else {
return Ok(None);
};
let Some((width, height)) = size.split_once('x') else {
return Ok(None);
};
let Some((x, y)) = position.split_once('+') else {
return Ok(None);
};
CaptureArea::new(
x.parse().context("invalid selection x")?,
y.parse().context("invalid selection y")?,
width.parse().context("invalid selection width")?,
height.parse().context("invalid selection height")?,
)
.map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use gio::prelude::StaticVariantType;
#[test]
fn parse_comma_selection_area() {
assert_eq!(
"10,20,300,400".parse::<CaptureArea>().unwrap(),
CaptureArea {
x: 10,
y: 20,
width: 300,
height: 400,
}
);
}
#[test]
fn parse_x11_selection_area() {
assert_eq!(
"300x400+10+20".parse::<CaptureArea>().unwrap(),
CaptureArea {
x: 10,
y: 20,
width: 300,
height: 400,
}
);
}
#[test]
fn reject_empty_selection_area() {
assert!("10,20,0,400".parse::<CaptureArea>().is_err());
}
#[test]
fn reject_selection_area_that_overflows_edges() {
assert!(CaptureArea::new(i32::MAX, 0, 1, 1).is_err());
assert!(CaptureArea::new(0, i32::MAX, 1, 1).is_err());
}
#[test]
fn parse_output_size_rounds_to_even() {
assert_eq!(
"641x361".parse::<OutputSize>().unwrap(),
OutputSize {
width: 640,
height: 360,
}
);
}
#[test]
fn reject_output_size_that_rounds_to_zero() {
assert!("1x2".parse::<OutputSize>().is_err());
assert!("2x1".parse::<OutputSize>().is_err());
}
#[test]
fn bitrate_zero_uses_codec_default() {
assert_eq!(bitrate_or_default(0, DEFAULT_VP8_BITRATE), 8_000_000);
assert_eq!(
bitrate_or_default(1_234_567, DEFAULT_VP8_BITRATE),
1_234_567
);
}
#[test]
fn audio_sources_match_requested_mode() {
assert!(audio_sources(AudioMode::None).is_empty());
assert_eq!(
audio_sources(AudioMode::Microphone),
vec![(DeviceClass::Source, "ocula-microphone-src")]
);
assert_eq!(
audio_sources(AudioMode::System),
vec![(DeviceClass::Sink, "ocula-system-audio-src")]
);
assert_eq!(
audio_sources(AudioMode::Both),
vec![
(DeviceClass::Sink, "ocula-system-audio-src"),
(DeviceClass::Source, "ocula-microphone-src"),
]
);
}
#[test]
fn preflight_audio_none_is_noop() {
preflight_audio(AudioMode::None).unwrap();
}
#[test]
fn raw_video_queue_drops_old_frames_to_stay_realtime() {
gst::init().unwrap();
let queue = make_video_queue("ocula-test-video-queue").unwrap();
assert_eq!(
queue.property::<u32>("max-size-buffers"),
RAW_VIDEO_QUEUE_MAX_BUFFERS
);
assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
assert_eq!(queue.property::<u64>("max-size-time"), 0);
}
#[test]
fn raw_audio_queue_buffers_without_leaking() {
gst::init().unwrap();
let queue = make_audio_queue("ocula-test-audio-queue").unwrap();
assert_eq!(queue.property::<u32>("max-size-buffers"), 0);
assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
assert_eq!(
queue.property::<u64>("max-size-time"),
RAW_AUDIO_QUEUE_MAX_TIME
);
}
#[test]
fn video_convert_uses_threaded_low_overhead_settings() {
gst::init().unwrap();
let convert = make_video_convert().unwrap();
assert!(convert.property::<u32>("n-threads") >= 1);
assert!(convert.property::<u32>("n-threads") <= MAX_VIDEO_PROCESSING_THREADS);
}
#[test]
fn direct_x11_mjpeg_skips_unnecessary_video_convert() {
assert!(can_skip_video_convert(
&VideoSource::X11 { show_pointer: true },
VideoCodec::Mjpeg,
));
assert!(!can_skip_video_convert(
&VideoSource::X11 { show_pointer: true },
VideoCodec::H264(H264Encoder::OpenH264),
));
assert!(!can_skip_video_convert(
&VideoSource::Portal {
fd: test_owned_fd(),
streams: Vec::new(),
},
VideoCodec::Mjpeg,
));
}
#[test]
fn video_effects_request_gl_elements_only_when_enabled() {
assert!(VideoEffect::None.required_elements().is_empty());
assert_eq!(
VideoEffect::Neon.required_elements(),
&[
"glupload",
"glshader",
"gldownload",
"videoconvert",
"capsfilter"
]
);
assert_eq!(
VideoEffect::Heatmap.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Glitch.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Ripple.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Glass.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Crt.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Holo.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Vapor.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Fracture.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Prism.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Aurora.required_elements(),
VideoEffect::Neon.required_elements()
);
assert_eq!(
VideoEffect::Kaleido.required_elements(),
VideoEffect::Neon.required_elements()
);
}
#[test]
fn shader_effects_include_precision_and_math() {
for effect in [
VideoEffect::Neon,
VideoEffect::Heatmap,
VideoEffect::Glitch,
VideoEffect::Ripple,
VideoEffect::Glass,
VideoEffect::Crt,
VideoEffect::Holo,
VideoEffect::Vapor,
VideoEffect::Fracture,
VideoEffect::Prism,
VideoEffect::Aurora,
VideoEffect::Kaleido,
] {
let shader = effect.fragment_shader().unwrap();
assert!(shader.contains("precision mediump float"));
assert!(shader.contains("texture2D"));
assert!(shader.contains("gl_FragColor"));
}
assert!(
VideoEffect::Neon
.fragment_shader()
.unwrap()
.contains("smoothstep")
);
assert!(
VideoEffect::Heatmap
.fragment_shader()
.unwrap()
.contains("distance")
);
assert!(
VideoEffect::Glitch
.fragment_shader()
.unwrap()
.contains("hash")
);
assert!(
VideoEffect::Ripple
.fragment_shader()
.unwrap()
.contains("refract_uv")
);
assert!(
VideoEffect::Glass
.fragment_shader()
.unwrap()
.contains("shard")
);
assert!(VideoEffect::Crt.fragment_shader().unwrap().contains("scan"));
assert!(
VideoEffect::Holo
.fragment_shader()
.unwrap()
.contains("interference")
);
assert!(
VideoEffect::Vapor
.fragment_shader()
.unwrap()
.contains("swirl")
);
assert!(
VideoEffect::Fracture
.fragment_shader()
.unwrap()
.contains("fracture")
);
assert!(
VideoEffect::Prism
.fragment_shader()
.unwrap()
.contains("chroma")
);
assert!(
VideoEffect::Aurora
.fragment_shader()
.unwrap()
.contains("curtain")
);
assert!(
VideoEffect::Kaleido
.fragment_shader()
.unwrap()
.contains("wedge")
);
}
#[test]
fn video_effect_builder_creates_gl_shader_chain() {
gst::init().unwrap();
let elements = make_video_effect_elements(VideoEffect::Glitch).unwrap();
assert_eq!(elements.len(), 6);
assert_eq!(elements[0].factory().unwrap().name(), "videoconvert");
assert_eq!(elements[1].factory().unwrap().name(), "capsfilter");
assert_eq!(elements[2].factory().unwrap().name(), "glupload");
assert_eq!(elements[3].factory().unwrap().name(), "glshader");
assert_eq!(elements[4].factory().unwrap().name(), "gldownload");
assert_eq!(elements[5].factory().unwrap().name(), "capsfilter");
assert!(elements[3].property::<String>("fragment").contains("hash"));
assert!(
make_video_effect_elements(VideoEffect::None)
.unwrap()
.is_empty()
);
}
#[test]
fn software_h264_and_vp8_request_i420_input_caps() {
gst::init().unwrap();
let h264_caps = make_video_format_caps(VideoCodec::H264(H264Encoder::OpenH264))
.unwrap()
.unwrap()
.property::<gst::Caps>("caps");
let vp8_caps = make_video_format_caps(VideoCodec::Vp8)
.unwrap()
.unwrap()
.property::<gst::Caps>("caps");
assert!(h264_caps.to_string().contains("I420"));
assert!(vp8_caps.to_string().contains("I420"));
assert!(make_video_format_caps(VideoCodec::Mjpeg).unwrap().is_none());
assert!(
make_video_format_caps(VideoCodec::H264(H264Encoder::Va))
.unwrap()
.is_none()
);
}
#[test]
fn videorate_caps_frame_rate_without_creating_duplicates() {
gst::init().unwrap();
let videorate = make_videorate("ocula-test-videorate", 15).unwrap();
assert!(videorate.property::<bool>("skip-to-first"));
assert!(videorate.property::<bool>("drop-only"));
assert_eq!(videorate.property::<i32>("max-rate"), 15);
assert!(videorate.property::<bool>("silent"));
}
#[test]
fn mux_queue_buffers_encoded_streams_for_latency_negotiation() {
gst::init().unwrap();
let queue = make_mux_queue("ocula-test-mux-queue").unwrap();
assert_eq!(queue.property::<u32>("max-size-buffers"), 0);
assert_eq!(queue.property::<u32>("max-size-bytes"), 0);
assert_eq!(queue.property::<u64>("max-size-time"), MUX_QUEUE_MAX_TIME);
}
#[test]
fn scale_selected_area_to_larger_portal_stream() {
let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(3840, 2160)>})");
assert_eq!(
scale_area_to_portal_streams(
CaptureArea::new(100, 50, 400, 300).unwrap(),
1920,
1080,
&[stream],
)
.unwrap(),
CaptureArea {
x: 200,
y: 100,
width: 800,
height: 600,
}
);
}
#[test]
fn scale_selected_area_to_offset_portal_stream() {
let stream =
stream_variant("(uint32 63, {'position': <(1920, 0)>, 'size': <(1920, 1080)>})");
assert_eq!(
scale_area_to_portal_streams(
CaptureArea::new(100, 50, 400, 300).unwrap(),
1920,
1080,
&[stream],
)
.unwrap(),
CaptureArea {
x: 2020,
y: 50,
width: 400,
height: 300,
}
);
}
#[test]
fn scale_selected_area_rejects_multiple_portal_streams() {
let left = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1920, 1080)>})");
let right =
stream_variant("(uint32 64, {'position': <(1920, 0)>, 'size': <(1920, 1080)>})");
let err = scale_area_to_portal_streams(
CaptureArea::new(100, 50, 400, 300).unwrap(),
3840,
1080,
&[left, right],
)
.unwrap_err()
.to_string();
assert!(err.contains("multiple portal streams"));
}
#[test]
fn scale_selected_area_rejects_area_outside_selector_screen() {
let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1920, 1080)>})");
let err = scale_area_to_portal_streams(
CaptureArea::new(1900, 0, 100, 100).unwrap(),
1920,
1080,
&[stream],
)
.unwrap_err()
.to_string();
assert!(err.contains("selector screen"));
}
#[test]
fn scale_selected_area_rejects_zero_scaled_dimensions() {
let stream = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1, 1)>})");
let err = scale_area_to_portal_streams(
CaptureArea::new(0, 0, 1, 1).unwrap(),
1920,
1080,
&[stream],
)
.unwrap_err()
.to_string();
assert!(err.contains("selection width must be > 0"));
}
#[test]
fn stream_bounds_rejects_empty_and_invalid_geometry() {
let zero_width = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(0, 1080)>})");
let overflowing =
stream_variant("(uint32 63, {'position': <(2147483647, 0)>, 'size': <(1, 1080)>})");
assert!(stream_bounds(&[]).is_none());
assert!(stream_bounds(&[zero_width]).is_none());
assert!(stream_bounds(&[overflowing]).is_none());
}
#[test]
fn stream_bounds_lays_out_streams_without_positions_like_compositor() {
let left = stream_variant("(uint32 63, {'size': <(100, 100)>})");
let right = stream_variant("(uint32 64, {'size': <(200, 50)>})");
assert_eq!(stream_bounds(&[left, right]), Some((0, 0, 300, 100)));
}
#[test]
fn portal_stream_size_uses_composited_bounds() {
let left = stream_variant("(uint32 63, {'size': <(100, 100)>})");
let right = stream_variant("(uint32 64, {'size': <(200, 50)>})");
assert_eq!(portal_stream_size(&[left, right]), Some((300, 100)));
}
#[test]
fn portal_stream_validation_accepts_reasonable_streams() {
let left = stream_variant("(uint32 63, {'position': <(0, 0)>, 'size': <(1920, 1080)>})");
let right =
stream_variant("(uint32 64, {'position': <(1920, 0)>, 'size': <(1920, 1080)>})");
validate_portal_streams(&[left, right]).unwrap();
}
#[test]
fn portal_stream_validation_rejects_excessive_stream_count() {
let stream = stream_variant("(uint32 63, {'size': <(100, 100)>})");
let streams = vec![stream; MAX_PORTAL_STREAMS + 1];
let err = validate_portal_streams(&streams).unwrap_err().to_string();
assert!(err.contains("exceeding limit"));
}
#[test]
fn portal_stream_validation_rejects_excessive_dimensions() {
let stream = stream_variant("(uint32 63, {'size': <(32769, 100)>})");
let err = validate_portal_streams(&[stream]).unwrap_err().to_string();
assert!(err.contains("exceeds supported limit"));
}
#[test]
fn portal_stream_validation_rejects_excessive_total_pixels() {
let stream = stream_variant("(uint32 63, {'size': <(20000, 20000)>})");
let err = validate_portal_streams(&[stream]).unwrap_err().to_string();
assert!(err.contains("pixel count"));
}
#[test]
fn compositor_layout_advances_by_stream_width_or_default() {
let sized = stream_variant("(uint32 63, {'size': <(100, 100)>})");
let missing_size = stream_variant("(uint32 64, {})");
assert_eq!(next_compositor_x(10, &sized).unwrap(), 110);
assert_eq!(next_compositor_x(10, &missing_size).unwrap(), 1930);
}
#[test]
fn compositor_layout_rejects_invalid_or_overflowing_width() {
let zero = stream_variant("(uint32 63, {'size': <(0, 100)>})");
let missing_size = stream_variant("(uint32 64, {})");
assert!(next_compositor_x(0, &zero).is_err());
assert!(next_compositor_x(i32::MAX, &missing_size).is_err());
}
#[test]
fn compositor_pad_offset_is_checked() {
assert_eq!(compositor_pad_offset(10, -5, "x").unwrap(), 15);
assert!(compositor_pad_offset(i32::MAX, -1, "x").is_err());
}
#[test]
fn portal_stream_edge_is_checked() {
assert_eq!(portal_stream_edge(10, 20, "right").unwrap(), 30);
assert!(portal_stream_edge(i32::MAX, 1, "right").is_err());
}
#[test]
fn portal_crop_margin_is_checked_and_even() {
assert_eq!(portal_crop_margin(11, 2, "left").unwrap(), 8);
assert!(portal_crop_margin(2, 11, "left").is_err());
assert!(portal_crop_margin(i32::MIN, 1, "left").is_err());
}
fn stream_variant(raw: &str) -> Stream {
glib::Variant::parse(Some(&Stream::static_variant_type()), raw)
.unwrap()
.get::<Stream>()
.unwrap()
}
fn test_owned_fd() -> OwnedFd {
std::fs::File::open("/dev/null").unwrap().into()
}
}