From 3122d4d6fc718147268150f6ee824ca62d5841a5 Mon Sep 17 00:00:00 2001
From: Matsushita256
Date: Sun, 13 Sep 2026 16:12:13 +0900
Subject: [PATCH 1/2] Add localized UI and structured conversion errors
---
src-tauri/src/conversion/error.rs | 36 +++++++++++
src-tauri/src/conversion/mod.rs | 50 +++++++--------
src-tauri/src/lib.rs | 14 ++--
src/App.tsx | 103 ++++++++++++++++++------------
src/components/AudioOptions.tsx | 20 +++---
src/components/ImageOptions.tsx | 14 ++--
src/components/ResizeOptions.tsx | 18 +++---
src/components/VideoOptions.tsx | 8 ++-
src/i18n.tsx | 82 ++++++++++++++++++++++++
src/main.tsx | 5 +-
10 files changed, 252 insertions(+), 98 deletions(-)
create mode 100644 src-tauri/src/conversion/error.rs
create mode 100644 src/i18n.tsx
diff --git a/src-tauri/src/conversion/error.rs b/src-tauri/src/conversion/error.rs
new file mode 100644
index 0000000..63fe813
--- /dev/null
+++ b/src-tauri/src/conversion/error.rs
@@ -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 for ApiError {
+ fn from(error: ConversionError) -> Self {
+ Self { code: error.code }
+ }
+}
diff --git a/src-tauri/src/conversion/mod.rs b/src-tauri/src/conversion/mod.rs
index fbe4cc0..ff3fa1c 100644
--- a/src-tauri/src/conversion/mod.rs
+++ b/src-tauri/src/conversion/mod.rs
@@ -1,3 +1,4 @@
+mod error;
mod ffmpeg;
mod types;
@@ -12,13 +13,14 @@ use std::{
use tauri::AppHandle;
use tauri_plugin_shell::ShellExt;
+pub use error::{ApiError, ConversionError, ErrorCode};
pub use types::{ConversionRequest, MediaDimensions, MediaProbeRequest};
pub async fn convert(
app: &AppHandle,
request: ConversionRequest,
cancel_flag: Arc,
-) -> Result, String> {
+) -> Result, ConversionError> {
let workspace = TempWorkspace::new()?;
let input_path = workspace
@@ -29,7 +31,7 @@ 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 args = ffmpeg::build_args(
&input_path,
@@ -37,16 +39,17 @@ pub async fn convert(
request.input_format,
request.output_format,
&request.options,
- )?;
+ )
+ .map_err(|_| ConversionError::new(ErrorCode::InvalidOptions))?;
// 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 {
@@ -54,7 +57,7 @@ pub async fn convert(
if cancel_flag.load(Ordering::Relaxed) {
// FFmpeg プロセスを強制終了する
let _ = child.kill();
- return Err("Conversion cancelled by user".into());
+ return Err(ConversionError::new(ErrorCode::ConversionCancelled));
}
// FFmpeg からのイベント(出力ログや終了通知)を確認する
@@ -62,23 +65,20 @@ pub async fn convert(
tokio::time::timeout(Duration::from_millis(100), rx.recv()).await
{
if payload.code != Some(0) {
- return Err(format!(
- "FFmpeg による変換処理に失敗しました (exit code: {:?})",
- payload.code
- ));
+ return Err(ConversionError::new(ErrorCode::ConversionFailed));
}
// 正常終了したためループを抜ける
break;
}
}
- fs::read(&output_path).map_err(|e| format!("出力ファイルの読み込みに失敗しました: {e}"))
+ fs::read(&output_path).map_err(|_| ConversionError::new(ErrorCode::OutputReadFailed))
}
pub async fn probe_dimensions(
app: &AppHandle,
request: MediaProbeRequest,
-) -> Result {
+) -> Result {
let workspace = TempWorkspace::new()?;
let input_path = workspace
.path()
@@ -86,12 +86,12 @@ pub async fn probe_dimensions(
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(),
@@ -105,31 +105,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 {
+fn parse_png_dimensions(data: &[u8]) -> Result {
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 })
@@ -140,18 +139,17 @@ struct TempWorkspace {
}
impl TempWorkspace {
- fn new() -> Result {
+ fn new() -> Result {
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 })
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e0fae96..a6aca92 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,6 +1,6 @@
mod conversion;
-use conversion::{ConversionRequest, MediaDimensions, MediaProbeRequest};
+use conversion::{ApiError, ConversionRequest, MediaDimensions, MediaProbeRequest};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::State;
@@ -15,12 +15,14 @@ async fn convert_file(
app: tauri::AppHandle,
state: State<'_, AppState>,
request: ConversionRequest,
-) -> Result, String> {
+) -> Result, ApiError> {
// 変換開始時にキャンセルフラグを「false(未キャンセル)」にリセット
state.cancel_flag.store(false, Ordering::Relaxed);
// conversion モジュールに cancel_flag (Arc) を渡して処理を実行
- conversion::convert(&app, request, state.cancel_flag.clone()).await
+ conversion::convert(&app, request, state.cancel_flag.clone())
+ .await
+ .map_err(ApiError::from)
}
#[tauri::command]
@@ -33,8 +35,10 @@ fn cancel_conversion(state: State<'_, AppState>) {
async fn probe_media_dimensions(
app: tauri::AppHandle,
request: MediaProbeRequest,
-) -> Result {
- conversion::probe_dimensions(&app, request).await
+) -> Result {
+ conversion::probe_dimensions(&app, request)
+ .await
+ .map_err(ApiError::from)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
diff --git a/src/App.tsx b/src/App.tsx
index d7332b4..afaa12e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -24,6 +24,7 @@ import { AudioOptions } from "./components/AudioOptions";
import { ImageOptions } from "./components/ImageOptions";
import { VideoOptions } from "./components/VideoOptions";
import { ResizeOptions } from "./components/ResizeOptions";
+import { type Translation, useTranslation } from "./i18n";
const MAX_DIMENSION = 16384;
@@ -38,7 +39,33 @@ function normalizeVideoDimension(value: number): number {
return Math.min(MAX_DIMENSION, clamped + 1);
}
+function apiErrorMessage(error: unknown, t: Translation): string {
+ const value = typeof error === "string" ? tryParseError(error) : error;
+ const code = typeof value === "string"
+ ? value
+ : typeof value === "object" && value !== null && "code" in value
+ ? (value as { code: unknown }).code
+ : null;
+ const errorKeys = {
+ conversion_cancelled: "error_conversion_cancelled",
+ invalid_options: "error_invalid_options",
+ input_write_failed: "error_input_write_failed",
+ ffmpeg_unavailable: "error_ffmpeg_unavailable",
+ ffmpeg_start_failed: "error_ffmpeg_start_failed",
+ conversion_failed: "error_conversion_failed",
+ output_read_failed: "error_output_read_failed",
+ probe_failed: "error_probe_failed",
+ } as const;
+ if (typeof code === "string" && code in errorKeys) return t(errorKeys[code as keyof typeof errorKeys]);
+ return t("error_unexpected");
+}
+
+function tryParseError(error: string): unknown {
+ try { return JSON.parse(error); } catch { return error; }
+}
+
function App() {
+ const { locale, setLocale, t } = useTranslation();
const [sourceFile, setSourceFile] = useState(null);
const [convertedFile, setConvertedFile] = useState(null);
const [convertedFileFormat, setConvertedFileFormat] =
@@ -152,9 +179,7 @@ function App() {
) as Format | undefined;
if (!detectedFormat) {
- setError(
- `${file.type} はサポートされていない形式です。対応形式:${SUPPORTED_FORMATS.join(", ")}`,
- );
+ setError(t("unsupportedFormat", { type: file.type, formats: SUPPORTED_FORMATS.join(", ") }));
return;
}
@@ -327,7 +352,7 @@ function App() {
try {
await invoke("cancel_conversion");
} catch (cancelError) {
- console.error("変換のキャンセルに失敗しました:", cancelError);
+ console.error(t("cancelFailed"), cancelError);
}
};
@@ -374,16 +399,7 @@ function App() {
setConvertedFileFormat(convertedFormat);
setConvertedSettingsKey(settingsKeyAtConversion);
} catch (error) {
- const errorMessage = String(error);
- if (errorMessage.includes("cancelled")) {
- setError("変換がキャンセルされました。");
- } else {
- setError(
- `Error during conversion: ${
- error instanceof Error ? error.message : errorMessage
- }`,
- );
- }
+ setError(apiErrorMessage(error, t));
} finally {
setIsConverting(false);
}
@@ -405,7 +421,7 @@ function App() {
await writeFile(filePath, new Uint8Array(buffer));
} catch (error) {
- console.error("ファイルの保存に失敗しました:", error);
+ console.error(t("saveFailed"), error);
}
};
@@ -538,10 +554,10 @@ function App() {
>
H
- 変換はかせ
+ {t("appName")}
-
+
- {isConverting ? "変換中" : "変換待機中"}
+ {isConverting ? t("converting") : t("waiting")}
+
@@ -569,7 +592,7 @@ function App() {
max-[980px]:gap-6
max-[980px]:py-7.5
"
- aria-label="ファイル変換"
+ aria-label={t("conversionWorkspace")}
>
{/* Input Panel */}
- 変換するファイル
+ {t("sourceTitle")}
- ファイルを追加してください
+ {t("sourceHint")}
@@ -639,7 +662,7 @@ function App() {
text-[#6276f7]
"
>
-
+
- {sourceFile?.name ?? "ファイルをここにドロップ"}
+ {sourceFile?.name ?? t("dropFile")}
- {sourceFile ? "別のファイルを選択" : "または、クリックして選択"}
+ {sourceFile ? t("chooseAnother") : t("chooseFile")}
@@ -688,7 +711,7 @@ function App() {
htmlFor="format"
className="mb-1.75 text-[11px] text-[#94a0b3]"
>
- 変換形式
+ {t("outputFormat")}
{isConverting ? (
@@ -757,7 +780,7 @@ function App() {
"
onClick={cancelConversion}
>
- キャンセル
+ {t("cancel")}
) : (
)}
@@ -825,11 +848,11 @@ function App() {
- 変換後のファイル
+ {t("outputTitle")}
- 変換結果がここに表示されます
+ {t("outputHint")}
@@ -850,7 +873,7 @@ function App() {
text-[#a9b4c4]
"
>
-
+
- {convertedFile?.name ?? "まだ変換されたファイルはありません"}
+ {convertedFile?.name ?? t("noOutput")}
{convertedFile
? `${Math.ceil(
convertedFile.size / 1024,
- ).toLocaleString()} KB ・ ${convertedFileFormat} 形式`
- : "ファイルを追加して変換を開始してください"}
+ ).toLocaleString()} KB ・ ${t("formatLabel", { format: convertedFileFormat ?? "" })}`
+ : t("addAndConvert")}
{convertedFile && (
@@ -895,7 +918,7 @@ function App() {
"
onClick={saveFile}
>
- ファイルに保存
+ {t("saveFile")}
)}
@@ -940,7 +963,7 @@ function App() {
>
⌃
- 詳細
+ {t("details")}
{detailsOpen && (
@@ -999,8 +1022,8 @@ function App() {
) : (
{sourceFile
- ? "この変換形式で設定可能な詳細オプションはありません"
- : "ファイルを選択するとオプションを設定できます"}
+ ? t("noOptions")
+ : t("chooseForOptions")}
)}
diff --git a/src/components/AudioOptions.tsx b/src/components/AudioOptions.tsx
index b94fefb..54ceade 100644
--- a/src/components/AudioOptions.tsx
+++ b/src/components/AudioOptions.tsx
@@ -4,6 +4,7 @@ import type {
AudioFormat,
} from "../formats";
import { isLossyAudioFormat } from "../formats";
+import { useTranslation } from "../i18n";
export type AudioOptionsProps = {
format: AudioFormat;
@@ -12,6 +13,7 @@ export type AudioOptionsProps = {
};
export function AudioOptions({ format, options, onChange }: AudioOptionsProps) {
+ const { t } = useTranslation();
const update = (
key: K,
value: AudioCompressionOptions[K],
@@ -31,7 +33,7 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) {
htmlFor="audio-sample-rate"
className="text-xs font-semibold text-[#415166]"
>
- サンプルレート
+ {t("sampleRate")}