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

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

現状の確認
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でいいなら問題ないが・・・。
そのため、実装するなら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にしておく。多分重くても問題ない。

一応参考。
ターミナル上で動画を再生する動画もあるが・・・古かったりバイナリじゃなかったり、Windowsはやっぱり滅んだほうがいいのでは?

そういやこんなのあったな。参考にできるだろうか?

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

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

多分あるんじゃねといろいろ探しました。結果、ほとんどなし。Linuxであれば数個はありますがね。
mcatに搭載されているrasteroidを使ってみよう。
mcatによる動画再生は重すぎるので要件に満たさない・・・。