Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src-tauri/src/conversion/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use serde::Serialize;

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
ConversionCancelled,
InvalidOptions,
InputWriteFailed,
FfmpegUnavailable,
FfmpegStartFailed,
ConversionFailed,
OutputReadFailed,
ProbeFailed,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiError {
pub code: ErrorCode,
}

#[derive(Debug)]
pub struct ConversionError {
pub code: ErrorCode,
}

impl ConversionError {
pub fn new(code: ErrorCode) -> Self {
Self { code }
}
}
impl From<ConversionError> for ApiError {
fn from(error: ConversionError) -> Self {
Self { code: error.code }
}
}
29 changes: 21 additions & 8 deletions src-tauri/src/conversion/ffmpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,52 @@ pub fn build_args(
if (input_format.is_video() || input_format == FileFormat::Gif)
&& output_format == FileFormat::Gif
{
return Ok(build_video_or_gif_to_gif_args(
return Ok(with_progress(build_video_or_gif_to_gif_args(
input_path,
output_path,
options,
));
)));
}

// GIF → 動画
if input_format == FileFormat::Gif && output_format.is_video() {
return Ok(build_gif_to_video_args(
return Ok(with_progress(build_gif_to_video_args(
input_path,
output_path,
output_format,
options,
));
)));
}

if input_format.is_video() && output_format.is_audio() {
return Ok(build_video_to_audio_args(input_path, output_path, options));
return Ok(with_progress(build_video_to_audio_args(
input_path,
output_path,
options,
)));
}

// 音声変換
if output_format.is_audio() {
return build_audio_args(input_path, output_path, output_format, options);
return build_audio_args(input_path, output_path, output_format, options)
.map(with_progress);
}

// 既存の変換はこれまでと同様 FFmpeg に任せる
Ok(build_default_args(
Ok(with_progress(build_default_args(
input_path,
output_path,
output_format,
options,
))
)))
}

fn with_progress(mut args: Vec<String>) -> Vec<String> {
args.splice(
0..0,
["-progress".into(), "pipe:1".into(), "-nostats".into()],
);
args
}

fn build_default_args(
Expand Down
118 changes: 86 additions & 32 deletions src-tauri/src/conversion/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
mod error;
mod ffmpeg;
mod progress;
mod types;

use std::{
Expand All @@ -10,15 +12,18 @@ use std::{
};

use tauri::AppHandle;
use tauri::Emitter;
use tauri_plugin_shell::ShellExt;

pub use error::{ApiError, ConversionError, ErrorCode};
use progress::{percentage, ConversionProgress};
pub use types::{ConversionRequest, MediaDimensions, MediaProbeRequest};

pub async fn convert(
app: &AppHandle,
request: ConversionRequest,
cancel_flag: Arc<AtomicBool>,
) -> Result<Vec<u8>, String> {
) -> Result<Vec<u8>, ConversionError> {
let workspace = TempWorkspace::new()?;

let input_path = workspace
Expand All @@ -29,69 +34,120 @@ pub async fn convert(
.join(format!("output.{}", request.output_format.extension()));

fs::write(&input_path, request.data)
.map_err(|e| format!("入力ファイルの作成に失敗しました: {e}"))?;
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;

let duration_ms = request
.duration_ms
.or(probe_duration_ms(app, &input_path).await);

let args = ffmpeg::build_args(
&input_path,
&output_path,
request.input_format,
request.output_format,
&request.options,
)?;
)
.map_err(|_| ConversionError::new(ErrorCode::InvalidOptions))?;

let emit_progress = |progress, state| {
let _ = app.emit(
"conversion-progress",
ConversionProgress {
conversion_id: request.conversion_id.clone(),
progress,
state,
},
);
};
emit_progress(None, "running");

// 1. .output() ではなく .spawn() を使用してプロセスを起動する
let (mut rx, child) = app
.shell()
.sidecar("ffmpeg")
.map_err(|e| format!("FFmpeg Sidecar の初期化に失敗しました: {e}"))?
.map_err(|_| ConversionError::new(ErrorCode::FfmpegUnavailable))?
.args(args)
.spawn()
.map_err(|e| format!("FFmpeg の起動に失敗しました: {e}"))?;
.map_err(|_| ConversionError::new(ErrorCode::FfmpegStartFailed))?;

// 2. FFmpeg の実行完了、またはキャンセルフラグの変更を非同期にループ監視する
loop {
// フロントエンドから cancel_conversion が呼ばれたかをチェック
if cancel_flag.load(Ordering::Relaxed) {
// FFmpeg プロセスを強制終了する
let _ = child.kill();
return Err("Conversion cancelled by user".into());
return Err(ConversionError::new(ErrorCode::ConversionCancelled));
}

// FFmpeg からのイベント(出力ログや終了通知)を確認する
if let Ok(Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload))) =
tokio::time::timeout(Duration::from_millis(100), rx.recv()).await
{
if payload.code != Some(0) {
return Err(format!(
"FFmpeg による変換処理に失敗しました (exit code: {:?})",
payload.code
));
if let Ok(Some(event)) = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(bytes) => {
if let Ok(line) = std::str::from_utf8(&bytes) {
if let Some(progress) = percentage(line.trim(), duration_ms) {
emit_progress(Some(progress), "running");
}
}
}
tauri_plugin_shell::process::CommandEvent::Terminated(payload) => {
if payload.code != Some(0) {
return Err(ConversionError::new(ErrorCode::ConversionFailed));
}
break;
}
_ => {}
}
// 正常終了したためループを抜ける
break;
}
}

fs::read(&output_path).map_err(|e| format!("出力ファイルの読み込みに失敗しました: {e}"))
let output =
fs::read(&output_path).map_err(|_| ConversionError::new(ErrorCode::OutputReadFailed))?;
emit_progress(Some(100), "completed");
Ok(output)
}

async fn probe_duration_ms(app: &AppHandle, input_path: &Path) -> Option<u64> {
let output = app
.shell()
.sidecar("ffmpeg")
.ok()?
.args([
"-hide_banner".into(),
"-i".into(),
input_path.to_string_lossy().into_owned(),
])
.output()
.await
.ok()?;
parse_duration_ms(&String::from_utf8_lossy(&output.stderr))
}

fn parse_duration_ms(output: &str) -> Option<u64> {
let value = output.split("Duration: ").nth(1)?.split(',').next()?.trim();
let mut fields = value.split(':');
let hours = fields.next()?.parse::<u64>().ok()?;
let minutes = fields.next()?.parse::<u64>().ok()?;
let seconds = fields.next()?.parse::<f64>().ok()?;
Some(((hours * 3_600 + minutes * 60) as f64 * 1_000.0 + seconds * 1_000.0).round() as u64)
}

pub async fn probe_dimensions(
app: &AppHandle,
request: MediaProbeRequest,
) -> Result<MediaDimensions, String> {
) -> Result<MediaDimensions, ConversionError> {
let workspace = TempWorkspace::new()?;
let input_path = workspace
.path()
.join(format!("input.{}", request.input_format.extension()));
let probe_path = workspace.path().join("probe.png");

fs::write(&input_path, request.data)
.map_err(|e| format!("入力ファイルの作成に失敗しました: {e}"))?;
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;

let output = app
.shell()
.sidecar("ffmpeg")
.map_err(|e| format!("FFmpeg Sidecar の初期化に失敗しました: {e}"))?
.map_err(|_| ConversionError::new(ErrorCode::FfmpegUnavailable))?
.args([
"-i".to_string(),
input_path.to_string_lossy().into_owned(),
Expand All @@ -105,31 +161,30 @@ pub async fn probe_dimensions(
])
.output()
.await
.map_err(|e| format!("メディアサイズの取得に失敗しました: {e}"))?;
.map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("メディアサイズの取得に失敗しました: {stderr}"));
return Err(ConversionError::new(ErrorCode::ProbeFailed));
}

let probe_data = fs::read(&probe_path)
.map_err(|e| format!("サイズ取得結果の読み込みに失敗しました: {e}"))?;
let probe_data =
fs::read(&probe_path).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?;

parse_png_dimensions(&probe_data)
}

fn parse_png_dimensions(data: &[u8]) -> Result<MediaDimensions, String> {
fn parse_png_dimensions(data: &[u8]) -> Result<MediaDimensions, ConversionError> {
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";

if data.len() < 24 || &data[..8] != PNG_SIGNATURE || &data[12..16] != b"IHDR" {
return Err("FFmpegから有効な画像サイズを取得できませんでした".into());
return Err(ConversionError::new(ErrorCode::ProbeFailed));
}

let width = u32::from_be_bytes(data[16..20].try_into().unwrap());
let height = u32::from_be_bytes(data[20..24].try_into().unwrap());

if width == 0 || height == 0 {
return Err("取得した画像サイズが不正です".into());
return Err(ConversionError::new(ErrorCode::ProbeFailed));
}

Ok(MediaDimensions { width, height })
Expand All @@ -140,18 +195,17 @@ struct TempWorkspace {
}

impl TempWorkspace {
fn new() -> Result<Self, String> {
fn new() -> Result<Self, ConversionError> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("一時ディレクトリ名の生成に失敗しました: {e}"))?
.map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?
.as_nanos();

let name = format!("henkanhakase-{}-{timestamp}", std::process::id());

let path = std::env::temp_dir().join(name);

fs::create_dir_all(&path)
.map_err(|e| format!("一時ディレクトリの作成に失敗しました: {e}"))?;
fs::create_dir_all(&path).map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?;

Ok(Self { path })
}
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/conversion/progress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversionProgress {
pub conversion_id: String,
pub progress: Option<u8>,
pub state: &'static str,
}

pub fn percentage(line: &str, duration_ms: Option<u64>) -> Option<u8> {
let duration_ms = duration_ms?;
let out_time_us = line.strip_prefix("out_time_us=")?.parse::<u64>().ok()?;
Some(((out_time_us / 1_000).saturating_mul(100) / duration_ms).min(99) as u8)
}

#[cfg(test)]
mod tests {
use super::percentage;

#[test]
fn calculates_progress_and_keeps_completion_for_the_final_event() {
assert_eq!(percentage("out_time_us=5000000", Some(10_000)), Some(50));
assert_eq!(percentage("out_time_us=15000000", Some(10_000)), Some(99));
}
}
4 changes: 4 additions & 0 deletions src-tauri/src/conversion/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,10 @@ impl ConversionOptions {
#[serde(rename_all = "camelCase")]
pub struct ConversionRequest {
pub data: Vec<u8>,
#[serde(default)]
pub conversion_id: String,
#[serde(default)]
pub duration_ms: Option<u64>,
pub input_format: FileFormat,
pub output_format: FileFormat,

Expand Down
Loading