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] " > - Upload + - {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")} update("sampleRate", Number(e.target.value))} className="rounded-[9px] border border-[#dfe5ef] bg-white px-3 py-2 text-xs text-[#40506a] outline-[#6578f7]" > - + @@ -52,7 +54,7 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { htmlFor="audio-channels" className="text-xs font-semibold text-[#415166]" > - チャンネル + {t("channels")} @@ -77,7 +79,7 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { htmlFor="audio-bit-depth" className="text-xs font-semibold text-[#415166]" > - ビット深度 + {t("bitDepth")} 320 kbps (最高音質) - ビットレートが高いほど一般に音質とファイルサイズが増加します + {t("bitrateHint")} )} diff --git a/src/components/ImageOptions.tsx b/src/components/ImageOptions.tsx index dc25b5a..bbc3f49 100644 --- a/src/components/ImageOptions.tsx +++ b/src/components/ImageOptions.tsx @@ -4,6 +4,7 @@ import { VIDEO_FORMATS, type VideoFormat, } from "../formats"; +import { useTranslation } from "../i18n"; export type ImageOptionsProps = { format: ImageFormat; @@ -34,6 +35,7 @@ export function ImageOptions({ gifMaxColors, onGifMaxColorsChange, }: ImageOptionsProps) { + const { t } = useTranslation(); return (
{format === "PNG" && ( @@ -43,7 +45,7 @@ export function ImageOptions({ htmlFor="png-compression" className="font-semibold text-[#415166]" > - 圧縮レベル(可逆圧縮) + {t("pngCompression")} {pngCompressionLevel} @@ -61,8 +63,8 @@ export function ImageOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 0 (低圧縮) - 9 (高圧縮) + {t("lowCompression")} + {t("highCompression")}
)} @@ -73,7 +75,7 @@ export function ImageOptions({ htmlFor="jpeg-quality" className="font-semibold text-[#415166]" > - 圧縮レベル(非可逆圧縮) + {t("jpegCompression")} {jpegQV} @@ -103,7 +105,7 @@ export function ImageOptions({ htmlFor="webp-quality" className="font-semibold text-[#415166]" > - 品質(非可逆圧縮) + {t("webpQuality")} {webpQV} @@ -165,7 +167,7 @@ export function ImageOptions({ htmlFor="gif-max-colors" className="font-semibold text-[#415166]" > - 色数 + {t("colors")} {gifMaxColors} 色 diff --git a/src/components/ResizeOptions.tsx b/src/components/ResizeOptions.tsx index 5f00443..91e670e 100755 --- a/src/components/ResizeOptions.tsx +++ b/src/components/ResizeOptions.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import type { MediaDimensions } from "../formats"; +import { useTranslation } from "../i18n"; type ResizeOptionsProps = { dimensions: MediaDimensions | null; @@ -28,6 +29,7 @@ export function ResizeOptions({ onWidthChange, onHeightChange, }: ResizeOptionsProps) { + const { t } = useTranslation(); const [widthInput, setWidthInput] = useState(String(width)); const [heightInput, setHeightInput] = useState(String(height)); @@ -52,14 +54,14 @@ export function ResizeOptions({ return (
- 出力サイズ + {t("outputSize")} {isLoading - ? "元のサイズを取得中…" + ? t("sourceSizeLoading") : dimensions - ? `元のサイズ: ${dimensions.width} × ${dimensions.height} px` - : "元のサイズを取得できません"} + ? t("sourceSize", dimensions) + : t("sourceSizeUnavailable")}
@@ -68,7 +70,7 @@ export function ResizeOptions({ {dimensions && (
)} diff --git a/src/components/VideoOptions.tsx b/src/components/VideoOptions.tsx index 3dedeb8..bee0b61 100644 --- a/src/components/VideoOptions.tsx +++ b/src/components/VideoOptions.tsx @@ -1,4 +1,5 @@ import type { VideoFormat } from "../formats"; +import { useTranslation } from "../i18n"; export type VideoOptionsProps = { format: VideoFormat; @@ -19,13 +20,14 @@ export function VideoOptions({ aviQV, onAviQVChange, }: VideoOptionsProps) { + const { t } = useTranslation(); return (
{(format === "MP4" || format === "MOV") && (
{videoCrf} @@ -52,7 +54,7 @@ export function VideoOptions({
{webmCrf} @@ -79,7 +81,7 @@ export function VideoOptions({
{aviQV} diff --git a/src/i18n.tsx b/src/i18n.tsx new file mode 100644 index 0000000..9d48459 --- /dev/null +++ b/src/i18n.tsx @@ -0,0 +1,82 @@ +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +export type Locale = "ja" | "en"; + +const messages = { + ja: { + appName: "変換はかせ", converting: "変換中", waiting: "変換待機中", language: "言語", + conversionWorkspace: "ファイル変換", sourceTitle: "変換するファイル", sourceHint: "ファイルを追加してください", + dropFile: "ファイルをここにドロップ", chooseAnother: "別のファイルを選択", chooseFile: "または、クリックして選択", + outputFormat: "変換形式", convertTo: "{format} に変換", cancel: "キャンセル", convert: "ファイルを変換", + reconvertChanged: "設定を変更して再変換", convertAgain: "もう一度変換", outputTitle: "変換後のファイル", + outputHint: "変換結果がここに表示されます", noOutput: "まだ変換されたファイルはありません", + addAndConvert: "ファイルを追加して変換を開始してください", formatLabel: "{format} 形式", saveFile: "ファイルに保存", + details: "詳細", unsupportedFormat: "{type} はサポートされていない形式です。対応形式:{formats}", + cancelFailed: "変換のキャンセルに失敗しました。", saveFailed: "ファイルの保存に失敗しました。", + noOptions: "この変換形式で設定可能な詳細オプションはありません", chooseForOptions: "ファイルを選択するとオプションを設定できます", + outputSize: "出力サイズ", sourceSizeLoading: "元のサイズを取得中…", sourceSize: "元のサイズ: {width} × {height} px", + sourceSizeUnavailable: "元のサイズを取得できません", width: "幅 (px)", height: "高さ (px)", lockAspect: "縦横比を固定", antiAliasing: "アンチエイリアス", + pngCompression: "圧縮レベル(可逆圧縮)", jpegCompression: "圧縮レベル(非可逆圧縮)", webpQuality: "品質(非可逆圧縮)", + lowCompression: "0(低圧縮)", highCompression: "9(高圧縮)", highQuality: "高品質", lowQuality: "低品質", colors: "色数", + videoCompression: "圧縮レベル(CRF)", fps: "FPS", sampleRate: "サンプルレート", channels: "チャンネル", auto: "自動(元ファイルと同じ)", + bitDepth: "ビット深度", flacCompression: "圧縮レベル(可逆圧縮)", vorbisQuality: "音質(Vorbis)", bitrate: "ビットレート", + bitrateHint: "ビットレートが高いほど一般に音質とファイルサイズが増加します", + error_conversion_cancelled: "変換をキャンセルしました。", error_invalid_options: "変換設定が正しくありません。", + error_input_write_failed: "入力ファイルを準備できませんでした。", error_ffmpeg_unavailable: "変換エンジンを起動できませんでした。", + error_ffmpeg_start_failed: "変換エンジンを開始できませんでした。", error_conversion_failed: "変換処理に失敗しました。", + error_output_read_failed: "変換結果を読み込めませんでした。", error_probe_failed: "元のサイズを取得できませんでした。", + error_unexpected: "予期しないエラーが発生しました。", + }, + en: { + appName: "Henkan Hakase", converting: "Converting", waiting: "Ready", language: "Language", + conversionWorkspace: "File conversion", sourceTitle: "Source file", sourceHint: "Add a file to convert", + dropFile: "Drop a file here", chooseAnother: "Choose another file", chooseFile: "or click to choose a file", + outputFormat: "Output format", convertTo: "Convert to {format}", cancel: "Cancel", convert: "Convert file", + reconvertChanged: "Reconvert with changed settings", convertAgain: "Convert again", outputTitle: "Converted file", + outputHint: "Your converted file appears here", noOutput: "No converted file yet", + addAndConvert: "Add a file to start converting", formatLabel: "{format} format", saveFile: "Save file", + details: "Details", unsupportedFormat: "{type} is not supported. Supported formats: {formats}", + cancelFailed: "Could not cancel the conversion.", saveFailed: "Could not save the file.", + noOptions: "No detailed options are available for this output format", chooseForOptions: "Choose a file to configure options", + outputSize: "Output size", sourceSizeLoading: "Reading source dimensions…", sourceSize: "Source size: {width} × {height} px", + sourceSizeUnavailable: "Could not read source dimensions", width: "Width (px)", height: "Height (px)", lockAspect: "Lock aspect ratio", antiAliasing: "Anti-aliasing", + pngCompression: "Compression level (lossless)", jpegCompression: "Compression level (lossy)", webpQuality: "Quality (lossy)", + lowCompression: "0 (low compression)", highCompression: "9 (high compression)", highQuality: "high quality", lowQuality: "low quality", colors: "Colors", + videoCompression: "Compression level (CRF)", fps: "FPS", sampleRate: "Sample rate", channels: "Channels", auto: "Auto (keep source)", + bitDepth: "Bit depth", flacCompression: "Compression level (lossless)", vorbisQuality: "Vorbis quality", bitrate: "Bitrate", + bitrateHint: "Higher bitrates generally increase quality and file size.", + error_conversion_cancelled: "Conversion was cancelled.", error_invalid_options: "The conversion settings are invalid.", + error_input_write_failed: "Could not prepare the input file.", error_ffmpeg_unavailable: "Could not start the conversion engine.", + error_ffmpeg_start_failed: "Could not start the conversion engine.", error_conversion_failed: "Conversion failed.", + error_output_read_failed: "Could not read the converted file.", error_probe_failed: "Could not read source dimensions.", + error_unexpected: "An unexpected error occurred.", + }, +} as const; + +type MessageKey = keyof typeof messages.ja; +export type Translation = (key: MessageKey, values?: Record) => string; + +const I18nContext = createContext<{ locale: Locale; setLocale: (locale: Locale) => void; t: Translation } | null>(null); + +function initialLocale(): Locale { + const saved = localStorage.getItem("locale"); + if (saved === "ja" || saved === "en") return saved; + return navigator.language.toLowerCase().startsWith("en") ? "en" : "ja"; +} + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(initialLocale); + useEffect(() => localStorage.setItem("locale", locale), [locale]); + const value = useMemo(() => ({ locale, setLocale, t: (key: MessageKey, values: Record = {}) => { + const template = messages[locale][key] as string; + return Object.entries(values).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), template); + } }), [locale]); + return {children}; +} + +export function useTranslation() { + const context = useContext(I18nContext); + if (!context) throw new Error("useTranslation must be used within I18nProvider"); + return context; +} diff --git a/src/main.tsx b/src/main.tsx index 49d3b7a..1736cff 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./App.css"; +import { I18nProvider } from "./i18n"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , ); From d65d3e7b1b93439d0348f83e9324cdd3e84ddb1b Mon Sep 17 00:00:00 2001 From: Matsushita256 Date: Sun, 13 Sep 2026 17:02:10 +0900 Subject: [PATCH 2/2] Add FFmpeg conversion progress reporting and localize option labels --- src-tauri/src/conversion/ffmpeg.rs | 29 ++++++++--- src-tauri/src/conversion/mod.rs | 72 ++++++++++++++++++++++++--- src-tauri/src/conversion/progress.rs | 26 ++++++++++ src-tauri/src/conversion/types.rs | 4 ++ src/App.tsx | 20 +++++++- src/components/AudioOptions.tsx | 28 +++++------ src/components/ConversionProgress.tsx | 12 +++++ src/components/ImageOptions.tsx | 14 +++--- src/components/VideoOptions.tsx | 12 ++--- src/i18n.tsx | 6 +++ 10 files changed, 179 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/conversion/progress.rs create mode 100644 src/components/ConversionProgress.tsx diff --git a/src-tauri/src/conversion/ffmpeg.rs b/src-tauri/src/conversion/ffmpeg.rs index 3999d44..b462d21 100644 --- a/src-tauri/src/conversion/ffmpeg.rs +++ b/src-tauri/src/conversion/ffmpeg.rs @@ -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) -> Vec { + args.splice( + 0..0, + ["-progress".into(), "pipe:1".into(), "-nostats".into()], + ); + args } fn build_default_args( diff --git a/src-tauri/src/conversion/mod.rs b/src-tauri/src/conversion/mod.rs index ff3fa1c..6c08c09 100644 --- a/src-tauri/src/conversion/mod.rs +++ b/src-tauri/src/conversion/mod.rs @@ -1,5 +1,6 @@ mod error; mod ffmpeg; +mod progress; mod types; use std::{ @@ -11,9 +12,11 @@ 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( @@ -33,6 +36,10 @@ pub async fn convert( fs::write(&input_path, request.data) .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, @@ -42,6 +49,18 @@ pub async fn convert( ) .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() @@ -61,18 +80,55 @@ pub async fn convert( } // 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(ConversionError::new(ErrorCode::ConversionFailed)); + 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(|_| ConversionError::new(ErrorCode::OutputReadFailed)) + 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 { + 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 { + let value = output.split("Duration: ").nth(1)?.split(',').next()?.trim(); + let mut fields = value.split(':'); + let hours = fields.next()?.parse::().ok()?; + let minutes = fields.next()?.parse::().ok()?; + let seconds = fields.next()?.parse::().ok()?; + Some(((hours * 3_600 + minutes * 60) as f64 * 1_000.0 + seconds * 1_000.0).round() as u64) } pub async fn probe_dimensions( diff --git a/src-tauri/src/conversion/progress.rs b/src-tauri/src/conversion/progress.rs new file mode 100644 index 0000000..ca1dca5 --- /dev/null +++ b/src-tauri/src/conversion/progress.rs @@ -0,0 +1,26 @@ +use serde::Serialize; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversionProgress { + pub conversion_id: String, + pub progress: Option, + pub state: &'static str, +} + +pub fn percentage(line: &str, duration_ms: Option) -> Option { + let duration_ms = duration_ms?; + let out_time_us = line.strip_prefix("out_time_us=")?.parse::().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)); + } +} diff --git a/src-tauri/src/conversion/types.rs b/src-tauri/src/conversion/types.rs index 470e4a0..9456f24 100644 --- a/src-tauri/src/conversion/types.rs +++ b/src-tauri/src/conversion/types.rs @@ -337,6 +337,10 @@ impl ConversionOptions { #[serde(rename_all = "camelCase")] pub struct ConversionRequest { pub data: Vec, + #[serde(default)] + pub conversion_id: String, + #[serde(default)] + pub duration_ms: Option, pub input_format: FileFormat, pub output_format: FileFormat, diff --git a/src/App.tsx b/src/App.tsx index afaa12e..f3e20e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; import { writeFile } from "@tauri-apps/plugin-fs"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import ArrowIcon from "./assets/arrow.svg"; import FileIcon from "./assets/file.svg"; import UploadIcon from "./assets/upload.svg"; @@ -24,6 +25,7 @@ import { AudioOptions } from "./components/AudioOptions"; import { ImageOptions } from "./components/ImageOptions"; import { VideoOptions } from "./components/VideoOptions"; import { ResizeOptions } from "./components/ResizeOptions"; +import { ConversionProgress } from "./components/ConversionProgress"; import { type Translation, useTranslation } from "./i18n"; const MAX_DIMENSION = 16384; @@ -85,6 +87,8 @@ function App() { const [convertedFormat, setConvertedFormat] = useState("PNG"); const [detailsOpen, setDetailsOpen] = useState(true); const [isConverting, setIsConverting] = useState(false); + const [conversionProgress, setConversionProgress] = useState(null); + const activeConversionId = useRef(null); const [error, setError] = useState(null); const [mediaDimensions, setMediaDimensions] = useState(null); @@ -123,6 +127,14 @@ function App() { const fileInput = useRef(null); + useEffect(() => { + let unlisten: (() => void) | undefined; + void listen<{ conversionId: string; progress: number | null }>("conversion-progress", (event) => { + if (event.payload.conversionId === activeConversionId.current) setConversionProgress(event.payload.progress); + }).then((dispose) => { unlisten = dispose; }); + return () => unlisten?.(); + }, []); + const isVideoOutput = VIDEO_FORMATS.includes( convertedFormat as VideoFormat, ); @@ -361,6 +373,9 @@ function App() { const settingsKeyAtConversion = conversionSettingsKey; setIsConverting(true); + setConversionProgress(null); + const conversionId = crypto.randomUUID(); + activeConversionId.current = conversionId; setError(null); try { @@ -377,6 +392,7 @@ function App() { stem, inputFormat: inputExtension, outputFormat: extension, + conversionId, options: buildConversionOptions(), }, @@ -402,6 +418,7 @@ function App() { setError(apiErrorMessage(error, t)); } finally { setIsConverting(false); + activeConversionId.current = null; } }; @@ -816,6 +833,7 @@ function App() { : t("convert")} )} + {isConverting && conversionProgress !== null && }
{/* Output Panel */} diff --git a/src/components/AudioOptions.tsx b/src/components/AudioOptions.tsx index 54ceade..47e8ae8 100644 --- a/src/components/AudioOptions.tsx +++ b/src/components/AudioOptions.tsx @@ -45,7 +45,7 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { - +
@@ -63,8 +63,8 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { className="rounded-[9px] border border-[#dfe5ef] bg-white px-3 py-2 text-xs text-[#40506a] outline-[#6578f7]" > - - + +
@@ -89,9 +89,9 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { } className="rounded-[9px] border border-[#dfe5ef] bg-white px-3 py-2 text-xs text-[#40506a] outline-[#6578f7]" > - - - + + +
)} @@ -124,8 +124,8 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 0 (高速) - 12 (高圧縮) + {t("fast")} + {t("highCompression12")}
)} @@ -156,8 +156,8 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- -1 (低品質) - 10 (高品質) + {t("rangeLowQuality", { value: -1 })} + {t("rangeHighQuality", { value: 10 })}
)} @@ -178,13 +178,13 @@ export function AudioOptions({ format, options, onChange }: AudioOptionsProps) { } className="rounded-[9px] border border-[#dfe5ef] bg-white px-3 py-2 text-xs text-[#40506a] outline-[#6578f7]" > - + - + - + - + {t("bitrateHint")} diff --git a/src/components/ConversionProgress.tsx b/src/components/ConversionProgress.tsx new file mode 100644 index 0000000..5dacd14 --- /dev/null +++ b/src/components/ConversionProgress.tsx @@ -0,0 +1,12 @@ +import { useTranslation } from "../i18n"; + +export function ConversionProgress({ progress }: { progress: number | null }) { + const { t } = useTranslation(); + const determinate = progress !== null; + return
+
+
+
+ {determinate ? t("progressPercent", { progress }) : t("progressWorking")} +
; +} diff --git a/src/components/ImageOptions.tsx b/src/components/ImageOptions.tsx index bbc3f49..b9a048b 100644 --- a/src/components/ImageOptions.tsx +++ b/src/components/ImageOptions.tsx @@ -93,8 +93,8 @@ export function ImageOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 1 (高品質) - 31 (低品質) + {t("rangeHighQuality", { value: 1 })} + {t("rangeLowQuality", { value: 31 })}
)} @@ -123,8 +123,8 @@ export function ImageOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 1 (低品質) - 100 (高品質) + {t("rangeLowQuality", { value: 1 })} + {t("rangeHighQuality", { value: 100 })}
)} @@ -170,7 +170,7 @@ export function ImageOptions({ {t("colors")} - {gifMaxColors} 色 + {t("colorCount", { value: gifMaxColors })}
- 2 色 - 256 色 + {t("colorCount", { value: 2 })} + {t("colorCount", { value: 256 })}
diff --git a/src/components/VideoOptions.tsx b/src/components/VideoOptions.tsx index bee0b61..3a18826 100644 --- a/src/components/VideoOptions.tsx +++ b/src/components/VideoOptions.tsx @@ -45,8 +45,8 @@ export function VideoOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 0 (高品質) - 51 (低品質) + {t("rangeHighQuality", { value: 0 })} + {t("rangeLowQuality", { value: 51 })}
)} @@ -72,8 +72,8 @@ export function VideoOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 0 (高品質) - 63 (低品質) + {t("rangeHighQuality", { value: 0 })} + {t("rangeLowQuality", { value: 63 })}
)} @@ -97,8 +97,8 @@ export function VideoOptions({ className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-[#e3e8f1] accent-[#586cec]" />
- 1 (高品質) - 31 (低品質) + {t("rangeHighQuality", { value: 1 })} + {t("rangeLowQuality", { value: 31 })}
)} diff --git a/src/i18n.tsx b/src/i18n.tsx index 9d48459..d1ba818 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -27,6 +27,9 @@ const messages = { error_ffmpeg_start_failed: "変換エンジンを開始できませんでした。", error_conversion_failed: "変換処理に失敗しました。", error_output_read_failed: "変換結果を読み込めませんでした。", error_probe_failed: "元のサイズを取得できませんでした。", error_unexpected: "予期しないエラーが発生しました。", + progressPercent: "変換中 {progress}%", progressWorking: "変換中…", + rangeHighQuality: "{value}(高品質)", rangeLowQuality: "{value}(低品質)", colorCount: "{value} 色", mono: "{value} ch(モノラル)", stereo: "{value} ch(ステレオ)", + hiRes: "96 kHz(ハイレゾ)", cdStandard: "16 bit(CD標準)", fast: "0(高速)", highCompression12: "12(高圧縮)", standardQuality: "標準音質", bestQuality: "最高音質", voiceLight: "軽量・音声向き", }, en: { appName: "Henkan Hakase", converting: "Converting", waiting: "Ready", language: "Language", @@ -51,6 +54,9 @@ const messages = { error_ffmpeg_start_failed: "Could not start the conversion engine.", error_conversion_failed: "Conversion failed.", error_output_read_failed: "Could not read the converted file.", error_probe_failed: "Could not read source dimensions.", error_unexpected: "An unexpected error occurred.", + progressPercent: "Converting {progress}%", progressWorking: "Converting…", + rangeHighQuality: "{value} (high quality)", rangeLowQuality: "{value} (low quality)", colorCount: "{value} colors", mono: "{value} ch (mono)", stereo: "{value} ch (stereo)", + hiRes: "96 kHz (hi-res)", cdStandard: "16 bit (CD standard)", fast: "0 (fast)", highCompression12: "12 (high compression)", standardQuality: "standard quality", bestQuality: "best quality", voiceLight: "lightweight for voice", }, } as const;