Open12

Image Protocolは動画を再生できるのか?

夕日夕日

きっかけ

ターミナルで動画を再生、もとい画像を表示すると言ってもASCIIではなく、Image Protocolを用いた表現ができるのではないかと考えたのがきっかけ。
調べればそれっぽいものはあるが、ほとんどがASCII、Windowsで動く実行ファイルに至ってはほとんど存在しないと考えて問題ない。

下のURLはWindowsで動くはずのリポジトリである。まだ試してない。
https://github.com/PK-cod3ch3mist/ASCII-Media-Player
https://github.com/kuwacom/Terminal-VideoPlayer

画像の表示でやりたいのはこれらか?
https://github.com/hzeller/timg
https://github.com/atanunq/viu

夕日夕日

現状の確認

Rustのratatui、Pythonのtextual、PythonのRichのいずれかの選択肢があり、現状使用しているTUIはratatuiだが、使い心地によってはPythonに移行する考えである。

ratatui-image

デコード

ratatui-imageを使った表示は遅すぎて不可能。1920*1080で0.1秒かかるのは厳しい(デフォルトのResize::Fit(None)、Intel i7-4770 CPU with Rust 1.37で32msかかる)
フレームレートが1でいいなら問題ないが・・・。
https://docs.rs/ratatui-image/8.0.1/ratatui_image/enum.FilterType.html
そのため、実装するならyaziのように独自のプロトコルを用いて高速に処理するほかない。
そこの処理を学ぶ時間が現状ないためClaudeに出力してもらった。コードは次の投稿で示す。

夕日夕日
// src/image.rs
use anyhow::{Result, bail};
use image::{DynamicImage, ImageReader, ImageFormat, GenericImageView};
use std::path::Path;
use std::io::{self, Write, Cursor};
use base64::Engine;

#[derive(Debug, Clone)]
pub enum ResizeMode {
    Fit,    // アスペクト比を維持してターミナルに収まるようにリサイズ
    Fill,   // ターミナル全体を埋めるようにリサイズ(アスペクト比無視)
    None,   // リサイズしない
}

impl ResizeMode {
    pub fn from_str(s: &str) -> Self {
        match s.to_lowercase().as_str() {
            "fit" => ResizeMode::Fit,
            "fill" => ResizeMode::Fill,
            "none" => ResizeMode::None,
            _ => ResizeMode::Fit, // デフォルト
        }
    }

    pub fn to_string(&self) -> String {
        match self {
            ResizeMode::Fit => "fit".to_string(),
            ResizeMode::Fill => "fill".to_string(),
            ResizeMode::None => "none".to_string(),
        }
    }
}

pub struct ImageHandler {
    pub image: DynamicImage,
    pub original_path: String,
    pub file_name: String,
}

impl ImageHandler {
    /// 画像ファイルを読み込む
    pub fn load(file_path: &str) -> Result<Self> {
        let path = Path::new(file_path);

        if !path.is_file() {
            bail!("画像ファイルが見つかりません: {}", path.display());
        }

        // サポートされている画像形式かチェック
        if !Self::is_supported_format(path) {
            bail!("サポートされていない画像形式です: {}", path.display());
        }

        let image = ImageReader::open(&path)?
            .with_guessed_format()?
            .decode()
            .map_err(|e| anyhow::anyhow!("画像のデコードに失敗しました: {}", e))?;

        let file_name = path.file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .into_owned();

        Ok(ImageHandler {
            image,
            original_path: file_path.to_string(),
            file_name,
        })
    }

    /// サポートされている画像形式かチェック
    fn is_supported_format(path: &Path) -> bool {
        if let Some(extension) = path.extension() {
            let ext = extension.to_string_lossy().to_lowercase();
            matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif")
        } else {
            false
        }
    }

    /// 画像をリサイズする(ターミナルの文字セルサイズを考慮)
    pub fn resize(&self, mode: &ResizeMode, terminal_cols: u32, terminal_rows: u32) -> DynamicImage {
        match mode {
            ResizeMode::Fit => self.resize_to_fit(terminal_cols, terminal_rows),
            ResizeMode::Fill => self.resize_to_fill(terminal_cols, terminal_rows),
            ResizeMode::None => self.image.clone(),
        }
    }

    /// アスペクト比を維持してターミナルに収まるようにリサイズ
    fn resize_to_fit(&self, terminal_cols: u32, terminal_rows: u32) -> DynamicImage {
        let (img_width, img_height) = self.image.dimensions();

        // ターミナルのピクセルサイズを推定(文字セルあたり約8x16ピクセル)
        let terminal_width_px = terminal_cols * 8;
        let terminal_height_px = terminal_rows * 16;

        // アスペクト比を計算
        let img_aspect = img_width as f32 / img_height as f32;
        let terminal_aspect = terminal_width_px as f32 / terminal_height_px as f32;

        let (new_width, new_height) = if img_aspect > terminal_aspect {
            // 画像の方が横長 -> 幅をターミナル幅に合わせる
            let new_width = terminal_width_px;
            let new_height = (new_width as f32 / img_aspect) as u32;
            (new_width, new_height)
        } else {
            // 画像の方が縦長 -> 高さをターミナル高さに合わせる
            let new_height = terminal_height_px;
            let new_width = (new_height as f32 * img_aspect) as u32;
            (new_width, new_height)
        };

        self.image.resize(new_width, new_height, image::imageops::FilterType::Lanczos3)
    }

    /// ターミナル全体を埋めるようにリサイズ
    fn resize_to_fill(&self, terminal_cols: u32, terminal_rows: u32) -> DynamicImage {
        let terminal_width_px = terminal_cols * 8;
        let terminal_height_px = terminal_rows * 16;
        self.image.resize_exact(terminal_width_px, terminal_height_px, image::imageops::FilterType::Lanczos3)
    }

    /// iTerm2 Image Protocolで表示するためのエスケープシーケンスを生成
    pub fn to_iterm2_protocol(&self, mode: &ResizeMode, terminal_cols: u32, terminal_rows: u32) -> Result<String> {
        let resized_image = self.resize(mode, terminal_cols, terminal_rows);

        // アルファチャンネルがあるかチェック
        let has_alpha = matches!(resized_image.color(), image::ColorType::Rgba8 | image::ColorType::Rgba16 | image::ColorType::Rgba32F);

        // フォーマットを選択(yaziの実装を参考)
        let (_format, data) = if has_alpha {
            // アルファチャンネルがある場合はPNG
            let mut buf = Vec::new();
            resized_image.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
                .map_err(|e| anyhow::anyhow!("PNG変換に失敗しました: {}", e))?;
            ("png", buf)
        } else {
            // アルファチャンネルがない場合はJPEG(圧縮効率が良い)
            let mut buf = Vec::new();
            resized_image.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg)
                .map_err(|e| anyhow::anyhow!("JPEG変換に失敗しました: {}", e))?;
            ("jpeg", buf)
        };

        // Base64エンコード
        let encoded = base64::engine::general_purpose::STANDARD.encode(&data);

        // iTerm2 Image Protocolのエスケープシーケンス
        // yaziと同様の形式を使用
        Ok(format!(
            "\x1b]1337;File=name={};inline=1;width={}px;height={}px:{}\x07",
            base64::engine::general_purpose::STANDARD.encode(self.file_name.as_bytes()),
            resized_image.width(),
            resized_image.height(),
            encoded
        ))
    }

    /// ターミナルサイズを取得(クロスプラットフォーム対応)
    pub fn get_terminal_size() -> Result<(u32, u32)> {
        // 方法1: crosstermを使用(推奨)
        if let Ok((cols, rows)) = ratatui::crossterm::terminal::size() {
            return Ok((cols as u32, rows as u32));
        }

        // 方法2: 環境変数をチェック
        if let (Ok(cols_str), Ok(rows_str)) = (std::env::var("COLUMNS"), std::env::var("LINES")) {
            if let (Ok(cols), Ok(rows)) = (cols_str.parse::<u32>(), rows_str.parse::<u32>()) {
                return Ok((cols, rows));
            }
        }

        // フォールバック: デフォルト値
        eprintln!("警告: ターミナルサイズを取得できませんでした。デフォルト値を使用します。");
        Ok((80, 24))
    }

    /// 画像情報を取得
    pub fn get_info(&self) -> ImageInfo {
        let (width, height) = self.image.dimensions();
        ImageInfo {
            file_name: self.file_name.clone(),
            file_path: self.original_path.clone(),
            width,
            height,
            format: self.guess_format(),
        }
    }

    /// 画像フォーマットを推測
    fn guess_format(&self) -> String {
        if let Some(ext) = Path::new(&self.original_path).extension() {
            ext.to_string_lossy().to_uppercase().to_string()
        } else {
            "UNKNOWN".to_string()
        }
    }
}

#[derive(Debug)]
pub struct ImageInfo {
    pub file_name: String,
    pub file_path: String,
    pub width: u32,
    pub height: u32,
    pub format: String,
}

/// iTerm2 Image Protocolで画像を表示する
pub fn display_image_iterm2(image_handler: &ImageHandler, mode: &ResizeMode) -> io::Result<()> {
    // ターミナルサイズを取得
    let (cols, rows) = ImageHandler::get_terminal_size()
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

    // UIエリアを考慮してサイズを調整(ヘッダーとフッターで6行使用)
    let display_rows = if rows > 6 { rows - 6 } else { rows };

    let protocol_string = image_handler.to_iterm2_protocol(mode, cols, display_rows)
        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

    // 画像表示前にカーソルを適切な位置に移動
    print!("\x1b[4;1H"); // 4行目(ヘッダーの下)に移動
    print!("{}", protocol_string);
    io::stdout().flush()?;
    Ok(())
}
夕日夕日

画像の大きさを考慮しないのであれば、よくあるBad Appleのようにスクロールさせてあたかも連続性のある出力を行うのがいいかもしれない?
wezterm imgcat ~~~を連続で行うようにしてみる。もしくは事前にすべて読み込んで表示する・・・遅いのが問題なのか?

夕日夕日

そういや今使っているWindows環境、なーぜーか、vcpkgが使えないんだよなぁ。

夕日夕日

一旦、1fpsで順々に再生するようにしてみよう。
使用する並列処理はsmolではなくtokioにしておく。多分重くても問題ない。

夕日夕日

AIの出力でできないかなと思ってたらなんかできた!!!
ちらつきひどいけど一歩前進か!

夕日夕日

とりあえずコードを読む限り、要らない動作がいくつかありそうなのでそこをどうにかしてみる。
それからできるなら以下のバージョンを"0.24"に上げる。
gstreamer = "0.22"
gstreamer-app = "0.22"
gstreamer-video = "0.22"