본문 바로가기
Rust/프로젝트

터미널에서 영상 시청하는 프로젝트 (2)

by lms0806 2026. 1. 13.
728x90
반응형

지난번 포스팅에서 유튜브 영상을 아스키 코드로 실행하는 프로젝트를 만들었었습니다.

 

그러나, 해당 프로젝트에서는 .exe 파일을 다운로드해서 불러와 사용한다는 불편한점이 있습니다.

 

해당 부분을 수정하기 위하여 확인해본 결과 yt-dlp.exe는 대체가 가능하다는 것이 확인되어, 해당 작업을 수행하였습니다.

 

rusty_ytdl이라는 라이브러리를 통하여 yt-dlp.exe없이 유튜브 영상을 실행할 수 있습니다.

[dependencies]
rusty_ytdl = "0.7.4"

다음과 같이 설정하여 프로젝트를 진행하고자 하였으나, 해당 버전에서는 문제가 발생하고 있는 상황이였습니다.

 

그래서 다음과 같이 버전명이 아닌 github를 import하는 방식으로 수정하여 해결하였습니다.

[dependencies]
rusty_ytdl = {git = "https://docs.rs/rusty_ytdl/latest/rusty_ytdl/"}

해당 라이브러리를 사용하면서 main.rs와 video.rs를 수정해주시면 됩니다.

  • main.rs
mod app;
mod ascii;
mod tui;
mod video;

use std::{
    io::{self, stdout},
    time::{Duration, Instant},
};

use app::App;
use ascii::rgb_to_colored_ascii;
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::EnterAlternateScreen,
};
use ratatui::{
    text::Text,
    widgets::{Paragraph, Wrap},
};
use tokio::time::sleep;
use video::VideoStream;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut url = String::new();
    println!("Input YouTube URL:");
    io::stdin().read_line(&mut url)?;
    let url = url.trim();

    let width = 120;
    let height = 50;
    let frame_time = Duration::from_millis(27);

    let mut app = App::new();
    let mut terminal = tui::init()?;
    execute!(stdout(), EnterAlternateScreen)?;

    // VideoStream::new가 async 함수로 변경되었으므로 .await 추가
    let mut video = VideoStream::new(url, width, height).await?;

    let mut rgb_buf = Vec::new();
    let mut ascii_lines = Vec::new();

    while !app.should_quit {
        let start = Instant::now();

        if event::poll(Duration::from_millis(1))? {
            if let Event::Key(k) = event::read()? {
                match k.code {
                    KeyCode::Char('q') => app.should_quit = true,
                    KeyCode::Char(' ') => app.playing = !app.playing,
                    _ => {}
                }
            }
        }

        if app.playing {
            if !video.read_frame(&mut rgb_buf) {
                break;
            }

            // 🔥 문자 비율 보정은 ASCII 단계에서만
            rgb_to_colored_ascii(&rgb_buf, video.width, video.height, &mut ascii_lines);

            terminal.draw(|f| {
                let p = Paragraph::new(Text::from(ascii_lines.clone())).wrap(Wrap { trim: false });
                f.render_widget(p, f.area());
            })?;
        }

        let elapsed = start.elapsed();
        if elapsed < frame_time {
            sleep(frame_time - elapsed).await;
        }
    }

    tui::restore()?;
    Ok(())
}
  • video.rs
use std::{
    io::Read,
    process::{Child, Command, Stdio},
};
use rusty_ytdl::{Video, VideoOptions, VideoQuality, VideoSearchOptions};

pub struct VideoStream {
    ffmpeg: Child,
    frame_size: usize,
    pub width: usize,
    pub height: usize,
}

impl VideoStream {
    pub async fn new(url: &str, width: u32, height: u32) -> anyhow::Result<Self> {
        // rusty_ytdl을 사용하여 비디오 정보 가져오기
        let video = Video::new(url)?;
        let info = video.get_info().await?;

        // 가장 좋은 화질의 비디오 포맷 선택 (오디오 제외, Video only)
        // 수정: &info -> &info.formats
        let format = rusty_ytdl::choose_format(&info.formats, &VideoOptions {
            quality: VideoQuality::HighestVideo,
            filter: VideoSearchOptions::Video,
            ..Default::default()
        })?;

        let stream_url = format.url.as_str();

        // 터미널 문자 비율 보정
        let char_aspect = 2.3;
        let real_height = (height as f32 * char_aspect) as u32;

        // 🔥 핵심: fps=30 강제
        // yt-dlp 파이프 대신 직접 추출한 URL을 ffmpeg 입력으로 사용
        let ffmpeg = Command::new("../tools/ffmpeg/ffmpeg.exe")
            .args([
                "-i",
                stream_url, // URL 직접 전달
                "-an",
                "-vf",
                &format!("scale={}:{},fps=30,format=rgb24", width, real_height),
                "-f",
                "rawvideo",
                "pipe:1",
            ])
            .stdin(Stdio::null()) // 입력 파이프 제거
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()?;

        Ok(Self {
            ffmpeg,
            frame_size: (width * real_height * 3) as usize,
            width: width as usize,
            height: real_height as usize,
        })
    }

    /// 그냥 "한 프레임"만 읽는다
    pub fn read_frame(&mut self, buf: &mut Vec<u8>) -> bool {
        buf.resize(self.frame_size, 0);
        self.ffmpeg.stdout.as_mut().unwrap().read_exact(buf).is_ok()
    }
}

이후, 영상만 재생되는게 너무 심심한나머지, 영상에 맞게 사운드를 추가하고 싶은 욕심이 생겼습니다.

 

영상에 사운드를 입히기 위해서는 rodio라는 라이브러리를 사용하면 됩니다.

[dependencies]
rodio = "0.19.0"

rodio를 사용하기 위해서는 main.rs와 video.rs를 수정하면 됩니다.

  • main.rs
mod app;
mod ascii;
mod tui;
mod video;

use std::{
    io::{self, stdout, Read},
    time::{Duration, Instant},
};

use app::App;
use ascii::rgb_to_colored_ascii;
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::EnterAlternateScreen,
};
use ratatui::{
    text::Text,
    widgets::{Paragraph, Wrap},
};
// rodio 임포트 추가
use rodio::{OutputStream, Sink, Source};
use tokio::time::sleep;
use video::VideoStream;

// --- 추가된 구조체: 오디오 스트림 소스 정의 ---
// rodio가 이터레이터의 오디오 속성(채널, 샘플레이트)을 알 수 있도록 직접 Source를 구현합니다.
struct AudioStreamSource {
    rx: std::sync::mpsc::IntoIter<i16>,
}

impl Iterator for AudioStreamSource {
    type Item = i16;
    fn next(&mut self) -> Option<Self::Item> {
        self.rx.next()
    }
}

impl Source for AudioStreamSource {
    fn current_frame_len(&self) -> Option<usize> {
        None // 길이를 알 수 없음 (스트리밍)
    }
    fn channels(&self) -> u16 {
        2 // 스테레오
    }
    fn sample_rate(&self) -> u32 {
        44100 // 44.1kHz
    }
    fn total_duration(&self) -> Option<Duration> {
        None // 전체 길이를 알 수 없음
    }
}
// ------------------------------------------

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut url = String::new();
    println!("Input YouTube URL:");
    io::stdin().read_line(&mut url)?;
    let url = url.trim();

    let width = 120;
    let height = 50;
    let frame_time = Duration::from_millis(30);

    let mut app = App::new();

    // 오디오 시스템 초기화
    let (_stream, stream_handle) = OutputStream::try_default()?;
    let sink = Sink::try_new(&stream_handle)?;

    let mut terminal = tui::init()?;
    execute!(stdout(), EnterAlternateScreen)?;

    let (mut video, mut audio) = VideoStream::new(url, width, height).await?;

    // --- 오디오 연결 로직 수정됨 ---
    // mpsc 채널 생성
    let (tx, rx) = std::sync::mpsc::channel::<i16>();

    if let Some(mut stdout) = audio.ffmpeg.stdout.take() {
        // 별도 스레드에서 ffmpeg 출력을 읽어 채널로 전송
        std::thread::spawn(move || {
            let mut buf = [0u8; 2];
            while stdout.read_exact(&mut buf).is_ok() {
                let sample = i16::from_le_bytes(buf);
                if tx.send(sample).is_err() { break; }
            }
        });
    }

    // 커스텀 소스 사용
    let source = AudioStreamSource { rx: rx.into_iter() };

    sink.append(source);
    sink.play();
    // ----------------------------

    let mut rgb_buf = Vec::new();
    let mut ascii_lines = Vec::new();

    while !app.should_quit {
        let start = Instant::now();

        if event::poll(Duration::from_millis(1))? {
            if let Event::Key(k) = event::read()? {
                match k.code {
                    KeyCode::Char('q') => app.should_quit = true,
                    KeyCode::Char(' ') => {
                        app.playing = !app.playing;
                        // 오디오 일시정지/재생 연동
                        if app.playing {
                            sink.play();
                        } else {
                            sink.pause();
                        }
                    },
                    _ => {}
                }
            }
        }

        if app.playing {
            if !video.read_frame(&mut rgb_buf) {
                break;
            }

            // 🔥 문자 비율 보정은 ASCII 단계에서만
            rgb_to_colored_ascii(&rgb_buf, video.width, video.height, &mut ascii_lines);

            terminal.draw(|f| {
                let p = Paragraph::new(Text::from(ascii_lines.clone())).wrap(Wrap { trim: false });
                f.render_widget(p, f.area());
            })?;
        }

        let elapsed = start.elapsed();
        if elapsed < frame_time {
            sleep(frame_time - elapsed).await;
        }
    }

    tui::restore()?;
    Ok(())
}
  • video.rs
use std::{
    io::Read,
    process::{Child, Command, Stdio},
};
use rusty_ytdl::{Video, VideoOptions, VideoQuality, VideoSearchOptions};

pub struct VideoStream {
    ffmpeg: Child,
    frame_size: usize,
    pub width: usize,
    pub height: usize,
}

// 오디오 프로세스를 별도로 관리하기 위한 구조체
pub struct AudioStream {
    pub ffmpeg: Child,
}

impl VideoStream {
    // 반환 타입을 변경하여 오디오 스트림도 함께 반환 (Video URL, Audio URL 각각 추출)
    pub async fn new(url: &str, width: u32, height: u32) -> anyhow::Result<(Self, AudioStream)> {
        // rusty_ytdl을 사용하여 비디오 정보 가져오기
        let video = Video::new(url)?;
        let info = video.get_info().await?;

        // 1. 비디오 포맷 선택
        let video_format = rusty_ytdl::choose_format(&info.formats, &VideoOptions {
            quality: VideoQuality::HighestVideo,
            filter: VideoSearchOptions::Video,
            ..Default::default()
        })?;

        // 2. 오디오 포맷 선택 (추가됨)
        let audio_format = rusty_ytdl::choose_format(&info.formats, &VideoOptions {
            quality: VideoQuality::HighestAudio,
            filter: VideoSearchOptions::Audio,
            ..Default::default()
        })?;

        let video_url = video_format.url.as_str();
        let audio_url = audio_format.url.as_str();

        // 터미널 문자 비율 보정
        let char_aspect = 2.3;
        let real_height = (height as f32 * char_aspect) as u32;

        // 비디오용 ffmpeg 실행
        let ffmpeg_video = Command::new("../tools/ffmpeg/ffmpeg.exe")
            .args([
                "-i",
                video_url,
                "-an",
                "-vf",
                &format!("scale={}:{},fps=30,format=rgb24", width, real_height),
                "-f",
                "rawvideo",
                "pipe:1",
            ])
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()?;

        // 오디오용 ffmpeg 실행 (추가됨)
        // PCM s16le 포맷, 44.1kHz, 2채널로 디코딩하여 파이프로 전송
        let ffmpeg_audio = Command::new("../tools/ffmpeg/ffmpeg.exe")
            .args([
                "-i",
                audio_url,
                "-vn",             // 비디오 제외
                "-f", "s16le",     // Signed 16-bit Little Endian PCM
                "-ac", "2",        // 2채널 (Stereo)
                "-ar", "44100",    // 샘플 레이트
                "-acodec", "pcm_s16le",
                "pipe:1",
            ])
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()?;

        Ok((
            Self {
                ffmpeg: ffmpeg_video,
                frame_size: (width * real_height * 3) as usize,
                width: width as usize,
                height: real_height as usize,
            },
            AudioStream {
                ffmpeg: ffmpeg_audio,
            }
        ))
    }

    /// 그냥 "한 프레임"만 읽는다
    pub fn read_frame(&mut self, buf: &mut Vec<u8>) -> bool {
        buf.resize(self.frame_size, 0);
        self.ffmpeg.stdout.as_mut().unwrap().read_exact(buf).is_ok()
    }
}

해당 버전까지 적용한 릴리즈 바이너리는 해당 사이트에서 다운로드 받으실 수 있습니다.

 

 

Release 1.1.0 · lms0806/tui_video

다음과 같은 사항이 수정되었습니다. 영상 사운드 추가 ascii 로직 변경 (싱글 스레드 -> 멀티 스레드) yt-dlp.exe 제거

github.com

 

728x90
반응형

댓글