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

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

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

오늘은 인스타 릴스를 보던 중, 레제춤을 아스키코드로 표현한 영상을 보게 되었습니다.

"나도 만들어 볼 수 있지않을까?

라는 생각을 하게 되었고, 이를 실천하기 위하여 프로젝트를 생성했습니다.

 

제가 현재 사용중인 Rust라는 언어를 사용하기로 했고, 터미널 출력은 rust의 라이브러리 중 하나인 Ratatui를 사용하기로 했습니다.

 

또한, youtube 영상을 가져올거기 때문에 yt-dlp.exe라는 프로그램을 다운로드 받고, 영상 출력을 하기 위하여 ffmpeg.exe라는 프로그램을 다운로드 받았습니다.

  • video.rs
use std::{
    io::Read,
    process::{Child, Command, Stdio},
};

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

impl VideoStream {
    pub fn new(url: &str, width: u32, height: u32) -> anyhow::Result<Self> {
        // yt-dlp → stdout
        let mut ytdlp = Command::new("../tools/yt-dlp/yt-dlp.exe")
            .args(["-f", "bestvideo[ext=mp4]/best", "-o", "-", url])
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()?;

        let ytdlp_stdout = ytdlp.stdout.take().unwrap();

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

        // 🔥 핵심: fps=30 강제
        let ffmpeg = Command::new("../tools/ffmpeg/ffmpeg.exe")
            .args([
                "-i",
                "pipe:0",
                "-an",
                "-vf",
                &format!("scale={}:{},fps=30,format=rgb24", width, real_height),
                "-f",
                "rawvideo",
                "pipe:1",
            ])
            .stdin(Stdio::from(ytdlp_stdout))
            .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()
    }
}

다음과 같은 코드를 통하여 video를 가져오고

  • axcii.rs
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use rayon::prelude::*; // Rayon 병렬 반복자 사용

const ASCII_TABLE: &[u8] = b"  .:-=+*#%@";

pub fn rgb_to_colored_ascii(rgb: &[u8], width: usize, height: usize, out: &mut Vec<Line>) {
    // 기존 벡터 재사용 대신 병렬 처리 결과를 수집
    // par_chunks_exact 등을 사용하여 y 단위로 병렬 처리 가능하지만,
    // Rayon은 collect()가 순서를 보장하므로 아래와 같이 작성 가능합니다.

    let lines: Vec<Line> = (0..height - 1)
        .into_par_iter() // 병렬 이터레이터로 변경
        .step_by(2)
        .map(|y| {
            let mut spans = Vec::with_capacity(width);
            let mut x = 0;
            let table_len = ASCII_TABLE.len();

            // ============================
            // SIMD path (4 pixels) - 로직 동일
            // ============================
            while x + 4 <= width {
                // ... (기존 로직과 동일) ...
                let mut lum = [0u8; 4];
                let mut colors = [(0u8, 0u8, 0u8); 4];

                for i in 0..4 {
                    let idx1 = (y * width + (x + i)) * 3;
                    let idx2 = ((y + 1) * width + (x + i)) * 3;

                    // 범위 체크 없이 접근 (unsafe)를 사용하면 더 빠르지만 안전을 위해 유지
                    let r = (rgb[idx1] as u16 + rgb[idx2] as u16) >> 1;
                    let g = (rgb[idx1 + 1] as u16 + rgb[idx2 + 1] as u16) >> 1;
                    let b = (rgb[idx1 + 2] as u16 + rgb[idx2 + 2] as u16) >> 1;

                    colors[i] = (r as u8, g as u8, b as u8);
                    lum[i] = ((54 * r + 183 * g + 19 * b) >> 8) as u8;
                }

                for i in 0..4 {
                    let idx = lum[i] as usize * (table_len - 1) / 255;
                    let ch = ASCII_TABLE[idx] as char;
                    let (r, g, b) = colors[i];

                    // Style::default() 호출 비용을 줄이려면 미리 상수로 정의하거나 재사용 고려
                    spans.push(Span::styled(
                         ch.to_string(),
                         Style::new().fg(Color::Rgb(
                            (r as f32 * 0.8) as u8,
                            (g as f32 * 0.8) as u8,
                            (b as f32 * 0.8) as u8,
                        )),
                    ));
                }
                x += 4;
            }

            // ============================
            // Scalar fallback - 로직 동일
            // ============================
            while x < width {
                 // ... (기존 로직과 동일) ...
                let i1 = (y * width + x) * 3;
                let i2 = ((y + 1) * width + x) * 3;

                let r = ((rgb[i1] as u16 + rgb[i2] as u16) >> 1) as u8;
                let g = ((rgb[i1 + 1] as u16 + rgb[i2 + 1] as u16) >> 1) as u8;
                let b = ((rgb[i1 + 2] as u16 + rgb[i2 + 2] as u16) >> 1) as u8;

                let l = ((54 * r as u16 + 183 * g as u16 + 19 * b as u16) >> 8) as u8;
                let idx = l as usize * (table_len - 1) / 255;
                let ch = ASCII_TABLE[idx] as char;

                spans.push(Span::styled(
                    ch.to_string(),
                    Style::new().fg(Color::Rgb(
                        (r as f32 * 0.8) as u8,
                        (g as f32 * 0.8) as u8,
                        (b as f32 * 0.8) as u8,
                    )),
                ));

                x += 1;
            }
            Line::from(spans)
        })
        .collect();

    *out = lines;
}

다음과 같은 코드를 통하여 ascii 코드를 표현합니다.

  • tui.rs
use crossterm::{
    execute,
    terminal::{disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io::stdout;

pub type Tui = Terminal<CrosstermBackend<std::io::Stdout>>;

pub fn init() -> anyhow::Result<Tui> {
    enable_raw_mode()?;
    let backend = CrosstermBackend::new(stdout());
    Ok(Terminal::new(backend)?)
}

pub fn restore() -> anyhow::Result<()> {
    disable_raw_mode()?;
    execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;
    Ok(())
}

이렇게 ratatui를 통하여 화면에 보여주는 방식으로 진행할 예정입니다.

  • 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<()> {
    println!("YouTube URL:");
    let mut url = String::new();
    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)?;

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

    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(())
}

해당 메인문을 실행하면, youtube링크를 입력받고, 이를 로딩한 후, 터미널에서 아스키코드로 영상을 표현하는 프로젝트를 완성하게 됩니다.

 

해당 프로젝트에 대한 최신 코드는 여기서 만나보실 수 있습니다.

728x90
반응형

댓글