use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use ntgcalls::{
AudioDescription, DeviceInfo, MediaDescription, MediaDevices, MediaSource, NTgCalls,
VideoDescription,
};
use crate::error::TgCallsError;
struct Probed {
has_video: bool,
has_audio: bool,
duration: Option<Duration>,
}
async fn probe(path: &str) -> Probed {
let empty = || Probed {
has_video: false,
has_audio: false,
duration: None,
};
let Ok(child) = tokio::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"stream=codec_type:format=duration",
"-of",
"default=noprint_wrappers=1",
path,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
else {
return empty();
};
let Ok(Ok(output)) = tokio::time::timeout(PROBE_TIMEOUT, child.wait_with_output()).await else {
return empty();
};
if !output.status.success() {
return empty();
}
let mut probed = empty();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if let Some(codec_type) = line.strip_prefix("codec_type=") {
match codec_type {
"video" => probed.has_video = true,
"audio" => probed.has_audio = true,
_ => {}
}
} else if let Some(secs) = line
.strip_prefix("duration=")
.and_then(|s| s.parse::<f64>().ok())
{
if secs.is_finite() && secs > 0.0 {
probed.duration = Some(Duration::from_secs_f64(secs));
}
}
}
probed
}
fn media_for(
source: &str,
video: bool,
audio: bool,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
match (video, audio) {
(true, true) => Media::av(source, source, width, height, fps),
(true, false) => Media::video(source, width, height, fps),
_ => Media::audio(source),
}
}
fn media_for_at(
source: &str,
offset: Duration,
video: bool,
audio: bool,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
match (video, audio) {
(true, true) => Media::av_at(source, source, offset, width, height, fps),
(true, false) => Media::video_at(source, offset, width, height, fps),
_ => Media::audio_at(source, offset),
}
}
pub async fn auto_media(source: &str, width: i16, height: i16, fps: u8) -> MediaDescription {
let probed = probe(source).await;
media_for(
source,
probed.has_video,
probed.has_audio,
width,
height,
fps,
)
}
pub async fn auto_media_at(
source: &str,
offset: Duration,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let probed = probe(source).await;
media_for_at(
source,
offset,
probed.has_video,
probed.has_audio,
width,
height,
fps,
)
}
pub async fn auto_media_probed(
source: &str,
width: i16,
height: i16,
fps: u8,
) -> (MediaDescription, Option<Duration>) {
let probed = probe(source).await;
(
media_for(
source,
probed.has_video,
probed.has_audio,
width,
height,
fps,
),
probed.duration,
)
}
pub async fn auto_media_at_probed(
source: &str,
offset: Duration,
width: i16,
height: i16,
fps: u8,
) -> (MediaDescription, Option<Duration>) {
let probed = probe(source).await;
(
media_for_at(
source,
offset,
probed.has_video,
probed.has_audio,
width,
height,
fps,
),
probed.duration,
)
}
pub struct Media;
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
const RECONNECT_FLAGS: &str =
"-reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2";
fn needs_reconnect(path: &str) -> bool {
!Path::new(path).exists()
}
fn run_with_timeout(
mut child: std::process::Child,
timeout: Duration,
) -> Option<std::process::Output> {
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => return None,
}
}
child.wait_with_output().ok()
}
pub async fn probe_duration(path: &str) -> Option<Duration> {
probe(path).await.duration
}
fn probe_dimensions(path: &str) -> Option<(u32, u32)> {
let child = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=s=x:p=0",
path,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let output = run_with_timeout(child, PROBE_TIMEOUT)?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().find(|l| l.contains('x'))?;
let mut parts = line.trim().split('x');
let w: u32 = parts.next()?.parse().ok()?;
let h: u32 = parts.next()?.parse().ok()?;
(w > 0 && h > 0).then_some((w, h))
}
fn fit_even(orig_w: u32, orig_h: u32, target_w: u32, target_h: u32) -> (u32, u32) {
let ratio = orig_w as f64 / orig_h as f64;
let mut new_w = target_w.min(orig_w).max(2);
let mut new_h = (new_w as f64 / ratio).round() as u32;
if new_h > target_h {
new_h = target_h.max(2);
new_w = (new_h as f64 * ratio).round() as u32;
}
if !new_w.is_multiple_of(2) {
new_w -= 1;
}
if !new_h.is_multiple_of(2) {
new_h -= 1;
}
(new_w.max(2), new_h.max(2))
}
fn resolve_dims(path: &str, width: i16, height: i16) -> (u32, u32) {
let base_w = (width.max(2) as u32) & !1;
let base_h = (height.max(2) as u32) & !1;
match probe_dimensions(path) {
Some((ow, oh)) => fit_even(ow, oh, base_w, base_h),
None => (base_w, base_h),
}
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn audio_cmd(path: &str, offset: Duration) -> String {
let reconnect = if needs_reconnect(path) {
RECONNECT_FLAGS
} else {
""
};
let seek = seek_flag(offset);
let path = shell_quote(path);
format!("ffmpeg -v error {seek}{reconnect} -i {path} -vn -f s16le -ar 48000 -ac 2 pipe:1")
}
fn video_cmd(path: &str, w: u32, h: u32, fps: u8, offset: Duration) -> String {
let reconnect = if needs_reconnect(path) {
RECONNECT_FLAGS
} else {
""
};
let seek = seek_flag(offset);
let path = shell_quote(path);
format!(
"ffmpeg -v error {seek}{reconnect} -i {path} -an -f rawvideo -pix_fmt yuv420p \
-vf scale={w}:{h},fps={fps} pipe:1"
)
}
fn seek_flag(offset: Duration) -> String {
if offset.is_zero() {
String::new()
} else {
format!("-ss {:.3} ", offset.as_secs_f64())
}
}
fn record_audio_cmd(path: &str) -> String {
format!(
"ffmpeg -v error -f s16le -ar 48000 -ac 2 -i pipe:0 -y {}",
shell_quote(path)
)
}
fn record_video_cmd(path: &str, w: u32, h: u32, fps: u8) -> String {
format!(
"ffmpeg -v error -f rawvideo -pix_fmt yuv420p -s {w}x{h} -r {fps} -i pipe:0 -y {}",
shell_quote(path)
)
}
impl Media {
pub fn audio(path: impl Into<String>) -> MediaDescription {
let path = path.into();
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::Shell,
sample_rate: 48000,
channel_count: 2,
input: audio_cmd(&path, Duration::ZERO),
keep_open: false,
}),
speaker: None,
camera: None,
screen: None,
}
}
pub fn audio_at(path: impl Into<String>, offset: Duration) -> MediaDescription {
let path = path.into();
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::Shell,
sample_rate: 48000,
channel_count: 2,
input: audio_cmd(&path, offset),
keep_open: false,
}),
speaker: None,
camera: None,
screen: None,
}
}
pub fn audio_raw(path: impl Into<String>) -> MediaDescription {
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::File,
sample_rate: 48000,
channel_count: 2,
input: path.into(),
keep_open: false,
}),
speaker: None,
camera: None,
screen: None,
}
}
pub fn video(path: impl Into<String>, width: i16, height: i16, fps: u8) -> MediaDescription {
let path = path.into();
let (w, h) = resolve_dims(&path, width, height);
MediaDescription {
microphone: None,
speaker: None,
camera: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: video_cmd(&path, w, h, fps, Duration::ZERO),
keep_open: false,
}),
screen: None,
}
}
pub fn video_at(
path: impl Into<String>,
offset: Duration,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let path = path.into();
let (w, h) = resolve_dims(&path, width, height);
MediaDescription {
microphone: None,
speaker: None,
camera: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: video_cmd(&path, w, h, fps, offset),
keep_open: false,
}),
screen: None,
}
}
pub fn av(
audio_path: impl Into<String>,
video_path: impl Into<String>,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let audio_path = audio_path.into();
let video_path = video_path.into();
let (w, h) = resolve_dims(&video_path, width, height);
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::Shell,
sample_rate: 48000,
channel_count: 2,
input: audio_cmd(&audio_path, Duration::ZERO),
keep_open: false,
}),
speaker: None,
camera: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: video_cmd(&video_path, w, h, fps, Duration::ZERO),
keep_open: false,
}),
screen: None,
}
}
pub fn av_at(
audio_path: impl Into<String>,
video_path: impl Into<String>,
offset: Duration,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let audio_path = audio_path.into();
let video_path = video_path.into();
let (w, h) = resolve_dims(&video_path, width, height);
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::Shell,
sample_rate: 48000,
channel_count: 2,
input: audio_cmd(&audio_path, offset),
keep_open: false,
}),
speaker: None,
camera: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: video_cmd(&video_path, w, h, fps, offset),
keep_open: false,
}),
screen: None,
}
}
pub fn screen(width: i16, height: i16, fps: u8) -> VideoDescription {
VideoDescription {
media_source: MediaSource::Desktop,
width,
height,
fps,
input: String::new(),
keep_open: false,
}
}
pub fn external_video(width: i16, height: i16, fps: u8) -> VideoDescription {
VideoDescription {
media_source: MediaSource::External,
width,
height,
fps,
input: String::new(),
keep_open: true,
}
}
pub fn external_audio(sample_rate: u32, channels: u8) -> AudioDescription {
AudioDescription {
media_source: MediaSource::External,
sample_rate,
channel_count: channels,
input: String::new(),
keep_open: true,
}
}
pub fn list_devices() -> Result<MediaDevices, TgCallsError> {
Ok(NTgCalls::get_media_devices()?)
}
pub fn microphone(device: &DeviceInfo) -> AudioDescription {
AudioDescription {
media_source: MediaSource::Device,
sample_rate: 48000,
channel_count: 2,
input: device.metadata.clone(),
keep_open: true,
}
}
pub fn camera(device: &DeviceInfo, width: i16, height: i16, fps: u8) -> VideoDescription {
VideoDescription {
media_source: MediaSource::Device,
width,
height,
fps,
input: device.metadata.clone(),
keep_open: true,
}
}
pub fn speaker(device: &DeviceInfo) -> AudioDescription {
AudioDescription {
media_source: MediaSource::Device,
sample_rate: 48000,
channel_count: 2,
input: device.metadata.clone(),
keep_open: true,
}
}
pub fn record_audio(path: impl Into<String>) -> MediaDescription {
let path = path.into();
MediaDescription {
microphone: Some(AudioDescription {
media_source: MediaSource::Shell,
sample_rate: 48000,
channel_count: 2,
input: record_audio_cmd(&path),
keep_open: false,
}),
speaker: None,
camera: None,
screen: None,
}
}
pub fn record_video(
path: impl Into<String>,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let path = path.into();
let w = (width.max(2) as u32) & !1;
let h = (height.max(2) as u32) & !1;
MediaDescription {
microphone: None,
speaker: None,
camera: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: record_video_cmd(&path, w, h, fps),
keep_open: false,
}),
screen: None,
}
}
pub fn record_screen(
path: impl Into<String>,
width: i16,
height: i16,
fps: u8,
) -> MediaDescription {
let path = path.into();
let w = (width.max(2) as u32) & !1;
let h = (height.max(2) as u32) & !1;
MediaDescription {
microphone: None,
speaker: None,
camera: None,
screen: Some(VideoDescription {
media_source: MediaSource::Shell,
width: w as i16,
height: h as i16,
fps,
input: record_video_cmd(&path, w, h, fps),
keep_open: false,
}),
}
}
}