diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ba780d6..6b2f13e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,8 @@ "dialog:default", "fs:default", "fs:allow-write-file", + "fs:allow-copy-file", + "fs:scope-temp-recursive", "shell:default" ] } diff --git a/src-tauri/src/conversion/error.rs b/src-tauri/src/conversion/error.rs index 63fe813..9361dd3 100644 --- a/src-tauri/src/conversion/error.rs +++ b/src-tauri/src/conversion/error.rs @@ -5,12 +5,14 @@ use serde::Serialize; pub enum ErrorCode { ConversionCancelled, InvalidOptions, - InputWriteFailed, FfmpegUnavailable, FfmpegStartFailed, ConversionFailed, - OutputReadFailed, ProbeFailed, + InputFileNotFound, + TempDirCreationFailed, + TimestampFetchFailed, + OutputFileNotGenerated } #[derive(Debug, Serialize)] diff --git a/src-tauri/src/conversion/mod.rs b/src-tauri/src/conversion/mod.rs index ff3fa1c..0ab3322 100644 --- a/src-tauri/src/conversion/mod.rs +++ b/src-tauri/src/conversion/mod.rs @@ -16,33 +16,60 @@ use tauri_plugin_shell::ShellExt; pub use error::{ApiError, ConversionError, ErrorCode}; pub use types::{ConversionRequest, MediaDimensions, MediaProbeRequest}; +/// 指定された一時ファイルを明示的に削除する関数 +pub fn remove_temp_file(path_str: &str) { + let path = PathBuf::from(path_str); + let temp_dir = std::env::temp_dir().join("henkanhakase"); + + // セキュリティ対策: 作成した一時ディレクトリ配下のファイルのみ削除を許可 + if path.starts_with(&temp_dir) && path.exists() { + let _ = fs::remove_file(path); + } +} + +/// アプリ起動時などに一時ディレクトリ内をすべて破棄・再作成する関数 +pub fn cleanup_temp_dir() { + let temp_dir = std::env::temp_dir().join("henkanhakase"); + if temp_dir.exists() { + let _ = fs::remove_dir_all(&temp_dir); + } +} + pub async fn convert( app: &AppHandle, request: ConversionRequest, cancel_flag: Arc, -) -> Result, ConversionError> { - let workspace = TempWorkspace::new()?; +) -> Result { + let input_path = PathBuf::from(&request.input_path); + if !input_path.exists() { + return Err(ConversionError::new(ErrorCode::InputFileNotFound)); + } + + let temp_dir = std::env::temp_dir().join("henkanhakase"); + fs::create_dir_all(&temp_dir) + .map_err(|_| ConversionError::new(ErrorCode::TempDirCreationFailed))?; - let input_path = workspace - .path() - .join(format!("input.{}", request.input_format.extension())); - let output_path = workspace - .path() - .join(format!("output.{}", request.output_format.extension())); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ConversionError::new(ErrorCode::TimestampFetchFailed))? + .as_nanos(); - fs::write(&input_path, request.data) - .map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; + let output_file = TempFile::new(temp_dir.join(format!( + "{}_{}.{}", + request.stem, + timestamp, + request.output_format.extension() + ))); let args = ffmpeg::build_args( &input_path, - &output_path, + output_file.path(), 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") @@ -51,48 +78,56 @@ pub async fn convert( .spawn() .map_err(|_| ConversionError::new(ErrorCode::FfmpegStartFailed))?; - // 2. FFmpeg の実行完了、またはキャンセルフラグの変更を非同期にループ監視する loop { - // フロントエンドから cancel_conversion が呼ばれたかをチェック if cancel_flag.load(Ordering::Relaxed) { - // FFmpeg プロセスを強制終了する let _ = child.kill(); 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(ConversionError::new(ErrorCode::ConversionFailed)); } - // 正常終了したためループを抜ける break; } } - fs::read(&output_path).map_err(|_| ConversionError::new(ErrorCode::OutputReadFailed)) + if !output_file.path().exists() { + return Err(ConversionError::new(ErrorCode::OutputFileNotGenerated)); + } + + let final_path = output_file.disarm(); + Ok(final_path.to_string_lossy().into_owned()) } pub async fn probe_dimensions( app: &AppHandle, request: MediaProbeRequest, ) -> Result { - let workspace = TempWorkspace::new()?; - let input_path = workspace - .path() - .join(format!("input.{}", request.input_format.extension())); - let probe_path = workspace.path().join("probe.png"); + let input_path = PathBuf::from(&request.input_path); + if !input_path.exists() { + return Err(ConversionError::new(ErrorCode::InputFileNotFound)); + } + + let temp_dir = std::env::temp_dir().join("henkanhakase"); + fs::create_dir_all(&temp_dir) + .map_err(|_| ConversionError::new(ErrorCode::TempDirCreationFailed))?; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ConversionError::new(ErrorCode::TimestampFetchFailed))? + .as_nanos(); - fs::write(&input_path, request.data) - .map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; + let probe_file = TempFile::new(temp_dir.join(format!("probe_{}.png", timestamp))); let output = app .shell() .sidecar("ffmpeg") .map_err(|_| ConversionError::new(ErrorCode::FfmpegUnavailable))? .args([ + "-y".to_string(), "-i".to_string(), input_path.to_string_lossy().into_owned(), "-frames:v".to_string(), @@ -101,7 +136,7 @@ pub async fn probe_dimensions( "image2pipe".to_string(), "-vcodec".to_string(), "png".to_string(), - probe_path.to_string_lossy().into_owned(), + probe_file.path().to_string_lossy().into_owned(), ]) .output() .await @@ -112,7 +147,7 @@ pub async fn probe_dimensions( } let probe_data = - fs::read(&probe_path).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?; + fs::read(probe_file.path()).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?; parse_png_dimensions(&probe_data) } @@ -134,33 +169,31 @@ fn parse_png_dimensions(data: &[u8]) -> Result Ok(MediaDimensions { width, height }) } -struct TempWorkspace { +/// スコープを抜けた際(エラー時やキャンセル時含む)に一時ファイルを自動削除するRAIIガード +pub struct TempFile { path: PathBuf, + keep: bool, } -impl TempWorkspace { - fn new() -> Result { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .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(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; - - Ok(Self { path }) +impl TempFile { + pub fn new(path: PathBuf) -> Self { + Self { path, keep: false } } - fn path(&self) -> &Path { + pub fn path(&self) -> &Path { &self.path } + + pub fn disarm(mut self) -> PathBuf { + self.keep = true; + self.path.clone() + } } -impl Drop for TempWorkspace { +impl Drop for TempFile { fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); + if !self.keep && self.path.exists() { + let _ = fs::remove_file(&self.path); + } } } diff --git a/src-tauri/src/conversion/types.rs b/src-tauri/src/conversion/types.rs index 470e4a0..6594e6c 100644 --- a/src-tauri/src/conversion/types.rs +++ b/src-tauri/src/conversion/types.rs @@ -80,6 +80,7 @@ impl FileFormat { pub fn is_video(self) -> bool { matches!(self, Self::Mp4 | Self::Webm | Self::Avi | Self::Mov) } + pub fn is_audio(self) -> bool { matches!( self, @@ -250,20 +251,6 @@ pub struct ConversionOptions { pub audio: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MediaProbeRequest { - pub data: Vec, - pub input_format: FileFormat, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MediaDimensions { - pub width: u32, - pub height: u32, -} - impl ConversionOptions { pub fn validate(&self, output_format: FileFormat) -> Result<(), String> { if let Some(width) = self.width { @@ -333,10 +320,28 @@ impl ConversionOptions { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaProbeRequest { + /// 入力ファイルの絶対パス + pub input_path: String, + pub _input_format: FileFormat, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaDimensions { + pub width: u32, + pub height: u32, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConversionRequest { - pub data: Vec, + /// 入力ファイルの絶対パス + pub input_path: String, + /// ファイル名 (拡張子なし) + pub stem: String, pub input_format: FileFormat, pub output_format: FileFormat, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6aca92..d424d78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ mod conversion; use conversion::{ApiError, ConversionRequest, MediaDimensions, MediaProbeRequest}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tauri::State; +use tauri::{State, WindowEvent}; #[derive(Default)] pub struct AppState { @@ -15,11 +15,8 @@ async fn convert_file( app: tauri::AppHandle, state: State<'_, AppState>, request: ConversionRequest, -) -> Result, ApiError> { - // 変換開始時にキャンセルフラグを「false(未キャンセル)」にリセット +) -> Result { state.cancel_flag.store(false, Ordering::Relaxed); - - // conversion モジュールに cancel_flag (Arc) を渡して処理を実行 conversion::convert(&app, request, state.cancel_flag.clone()) .await .map_err(ApiError::from) @@ -27,7 +24,6 @@ async fn convert_file( #[tauri::command] fn cancel_conversion(state: State<'_, AppState>) { - // フラグを「true(キャンセル済み)」に更新 state.cancel_flag.store(true, Ordering::Relaxed); } @@ -41,8 +37,16 @@ async fn probe_media_dimensions( .map_err(ApiError::from) } +#[tauri::command] +fn cleanup_temp_file(path: String) { + conversion::remove_temp_file(&path); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // アプリ起動時に前回の古い一時ディレクトリを初期化・全削除 + conversion::cleanup_temp_dir(); + tauri::Builder::default() .manage(AppState::default()) .plugin(tauri_plugin_shell::init()) @@ -52,8 +56,15 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ convert_file, probe_media_dimensions, - cancel_conversion + cancel_conversion, + cleanup_temp_file ]) + // ウィンドウイベントの監視を追加(アプリ終了時に一時ディレクトリごと削除) + .on_window_event(|_window, event| { + if let WindowEvent::CloseRequested { .. } = event { + conversion::cleanup_temp_dir(); + } + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.tsx b/src/App.tsx index afaa12e..a163d18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,87 +1,59 @@ import { invoke } from "@tauri-apps/api/core"; -import { save } from "@tauri-apps/plugin-dialog"; -import { writeFile } from "@tauri-apps/plugin-fs"; -import { useMemo, useRef, useState } from "react"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import { copyFile } from "@tauri-apps/plugin-fs"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ArrowIcon from "./assets/arrow.svg"; import FileIcon from "./assets/file.svg"; import UploadIcon from "./assets/upload.svg"; +import { AudioOptions } from "./components/AudioOptions"; +import { ImageOptions } from "./components/ImageOptions"; +import { ResizeOptions } from "./components/ResizeOptions"; +import { VideoOptions } from "./components/VideoOptions"; import { - IMAGE_FORMATS, - VIDEO_FORMATS, AUDIO_FORMATS, - SUPPORTED_FORMATS, - type ImageFormat, - type VideoFormat, + type AudioCompressionOptions, type AudioFormat, type Format, - type AudioCompressionOptions, - type MediaDimensions, - mimeTypes, formatToExtension, + IMAGE_FORMATS, + type ImageFormat, isAudioFormat, + type MediaDimensions, + SUPPORTED_FORMATS, + VIDEO_FORMATS, + type VideoFormat, } from "./formats.ts"; -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; - -function clampDimension(value: number): number { - if (!Number.isFinite(value)) return 1; - return Math.min(MAX_DIMENSION, Math.max(1, Math.round(value))); -} - -function normalizeVideoDimension(value: number): number { - const clamped = clampDimension(value); - if (clamped % 2 === 0) return clamped; - 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; } -} +import { useTranslation } from "./i18n"; +import { + clampDimension, + MAX_DIMENSION, + normalizeVideoDimension, +} from "./utils/dimension.ts"; +import { apiErrorMessage } from "./utils/error.ts"; +import { detectFormatFromPath, getExtensionFromPath } from "./utils/path.ts"; function App() { const { locale, setLocale, t } = useTranslation(); - const [sourceFile, setSourceFile] = useState(null); - const [convertedFile, setConvertedFile] = useState(null); - const [convertedFileFormat, setConvertedFileFormat] = - useState(null); + const [sourceFileName, setSourceFileName] = useState(null); + const [sourceFilePath, setSourceFilePath] = useState(null); + const [sourceFileType, setSourceFileType] = useState(null); + + const [convertedFilePath, setConvertedFilePath] = useState( + null, + ); + const [convertedFileName, setConvertedFileName] = useState( + null, + ); + + const [convertedFileFormat, setConvertedFileFormat] = useState( + null, + ); const [convertedSettingsKey, setConvertedSettingsKey] = useState< string | null >(null); - const sourceFormat = useMemo( - () => - sourceFile - ? (Object.keys(mimeTypes).find((format) => - mimeTypes[format as Format].includes(sourceFile.type), - ) as Format) - : null, - [sourceFile], - ); + + const sourceFormat = sourceFileType; const [convertedFormat, setConvertedFormat] = useState("PNG"); const [detailsOpen, setDetailsOpen] = useState(true); const [isConverting, setIsConverting] = useState(false); @@ -121,23 +93,20 @@ function App() { pcmBitDepth: 16, }); - const fileInput = useRef(null); - - const isVideoOutput = VIDEO_FORMATS.includes( - convertedFormat as VideoFormat, - ); + const isVideoOutput = VIDEO_FORMATS.includes(convertedFormat as VideoFormat); // 詳細パネルの開閉ではなく、実際に変換結果へ影響する設定だけを比較する。 const conversionSettingsKey = JSON.stringify({ convertedFormat, - resize: !isAudioFormat(convertedFormat) && mediaDimensions - ? { - width: resizeWidth, - height: resizeHeight, - aspectRatioLocked, - antiAliasing, - } - : null, + resize: + !isAudioFormat(convertedFormat) && mediaDimensions + ? { + width: resizeWidth, + height: resizeHeight, + aspectRatioLocked, + antiAliasing, + } + : null, pngCompressionLevel, jpegQV, webpQV, @@ -149,7 +118,9 @@ function App() { audioCompression, }); const conversionSettingsChanged = - convertedFile !== null && convertedSettingsKey !== conversionSettingsKey; + convertedFilePath !== null && + convertedFileName !== null && + convertedSettingsKey !== conversionSettingsKey; const availableOutputFormats = useMemo(() => { if (!sourceFormat) { @@ -171,77 +142,138 @@ function App() { return [...IMAGE_FORMATS]; }, [sourceFormat]); - const selectFile = async (file?: File) => { - if (!file) return; - - const detectedFormat = Object.keys(mimeTypes).find((format) => - mimeTypes[format as Format].includes(file.type), - ) as Format | undefined; - - if (!detectedFormat) { - setError(t("unsupportedFormat", { type: file.type, formats: SUPPORTED_FORMATS.join(", ") })); - return; + // 古い一時ファイルを破棄するヘルパー + const cleanupOldTempFile = useCallback((filePath: string | null) => { + if (filePath) { + invoke("cleanup_temp_file", { path: filePath }).catch(console.error); } + }, []); + + // ファイルパスを受け取って内部状態を更新し、メディア情報の計測を行う共通処理 + const processSelectedFilePath = useCallback( + async (filePath: string) => { + const fileName = filePath.split(/[/\\]/).pop() ?? filePath; + const detectedFormat = detectFormatFromPath(filePath); + + if (!detectedFormat) { + setError( + t("unsupportedFormat", { + type: getExtensionFromPath(filePath), + formats: SUPPORTED_FORMATS.join(", "), + }), + ); + return; + } - setSourceFile(file); - setConvertedFile(null); - setConvertedFileFormat(null); - setConvertedSettingsKey(null); - setError(null); - setMediaDimensions(null); - setDimensionProbeError(null); - setAspectRatioLocked(true); - setAntiAliasing(true); - setLastChangedResizeAxis("width"); - - const probeId = ++dimensionProbeId.current; - - if (detectedFormat === "GIF") { - setConvertedFormat("MP4"); - } else if (VIDEO_FORMATS.includes(detectedFormat as VideoFormat)) { - setConvertedFormat("GIF"); - } else if (AUDIO_FORMATS.includes(detectedFormat as AudioFormat)) { - setConvertedFormat("MP3"); - } else { - setConvertedFormat("PNG"); - } + console.log( + `fileName: ${fileName},\nfilePath: ${filePath},\ndetectedFormat: ${detectedFormat}`, + ); + setSourceFileName(fileName); + setSourceFilePath(filePath); + setSourceFileType(detectedFormat); + + // 新しいファイルが選ばれたら旧一時ファイルを消去 + setConvertedFilePath((prevPath) => { + cleanupOldTempFile(prevPath); + return null; + }); + setConvertedFileName(null); + setConvertedFileFormat(null); + setConvertedSettingsKey(null); + setError(null); + setMediaDimensions(null); + setDimensionProbeError(null); + setAspectRatioLocked(true); + setAntiAliasing(true); + setLastChangedResizeAxis("width"); + + const probeId = ++dimensionProbeId.current; + + if (detectedFormat === "GIF") { + setConvertedFormat("MP4"); + } else if (VIDEO_FORMATS.includes(detectedFormat as VideoFormat)) { + setConvertedFormat("GIF"); + } else if (AUDIO_FORMATS.includes(detectedFormat as AudioFormat)) { + setConvertedFormat("MP3"); + } else { + setConvertedFormat("PNG"); + } - if (AUDIO_FORMATS.includes(detectedFormat as AudioFormat)) { - setIsProbingDimensions(false); - return; - } + if (AUDIO_FORMATS.includes(detectedFormat as AudioFormat)) { + setIsProbingDimensions(false); + return; + } - setIsProbingDimensions(true); + setIsProbingDimensions(true); - try { - const buffer = await file.arrayBuffer(); - const dimensions = await invoke( - "probe_media_dimensions", - { - request: { - data: Array.from(new Uint8Array(buffer)), - inputFormat: formatToExtension(detectedFormat), + try { + const dimensions = await invoke( + "probe_media_dimensions", + { + request: { + inputPath: filePath, + inputFormat: formatToExtension(detectedFormat), + }, }, - }, - ); + ); + + if (probeId !== dimensionProbeId.current) return; + + setMediaDimensions(dimensions); + setResizeWidth(clampDimension(dimensions.width)); + setResizeHeight(clampDimension(dimensions.height)); + } catch (probeError) { + if (probeId !== dimensionProbeId.current) return; + setDimensionProbeError( + `サイズの取得に失敗しました: ${String(probeError)}`, + ); + } finally { + if (probeId === dimensionProbeId.current) { + setIsProbingDimensions(false); + } + } + }, + [cleanupOldTempFile, t], + ); - if (probeId !== dimensionProbeId.current) return; + // Tauri 公式のネイティブファイル選択ダイアログを開く + const selectFileWithDialog = async () => { + try { + const selected = await open({ + multiple: false, + directory: false, + }); - setMediaDimensions(dimensions); - setResizeWidth(clampDimension(dimensions.width)); - setResizeHeight(clampDimension(dimensions.height)); - } catch (probeError) { - if (probeId !== dimensionProbeId.current) return; - setDimensionProbeError( - `サイズの取得に失敗しました: ${String(probeError)}`, - ); - } finally { - if (probeId === dimensionProbeId.current) { - setIsProbingDimensions(false); + if (selected && typeof selected === "string") { + await processSelectedFilePath(selected); } + } catch (err) { + setError(t("fileSelectError", { error: String(err) })); } }; + // Tauri の Drag & Drop イベントリスナーを登録 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupDragDrop = async () => { + unlisten = await getCurrentWebview().onDragDropEvent((event) => { + if (event.payload.type === "drop") { + const paths = event.payload.paths; + if (paths && paths.length > 0) { + processSelectedFilePath(paths[0]); + } + } + }); + }; + + setupDragDrop(); + + return () => { + if (unlisten) unlisten(); + }; + }, [processSelectedFilePath]); + const buildConversionOptions = () => { const resizeOptions = !isAudioFormat(convertedFormat) && mediaDimensions @@ -292,7 +324,6 @@ function App() { }; default: if (isAudioFormat(convertedFormat)) { - // sampleRateやchannelsが 0 (Auto) の場合は undefined にし、Rust/FFmpeg側で引数を省略できるようにする const commonAudio = { sampleRate: audioCompression.sampleRate > 0 @@ -357,7 +388,7 @@ function App() { }; const convertFile = async () => { - if (!sourceFile || !sourceFormat) return; + if (!sourceFilePath || !sourceFileName || !sourceFormat) return; const settingsKeyAtConversion = conversionSettingsKey; setIsConverting(true); @@ -366,36 +397,29 @@ function App() { try { const extension = formatToExtension(convertedFormat); const inputExtension = formatToExtension(sourceFormat); + const stem = sourceFileName.replace(/\.[^.]+$/, ""); - const stem = sourceFile.name.replace(/\.[^.]+$/, ""); - - const buffer = await sourceFile.arrayBuffer(); - - const result = await invoke("convert_file", { + // Rust側へファイルの絶対パスを引数として渡し、出力された一時ファイルの絶対パスを受け取る + const outputTempPath = await invoke("convert_file", { request: { - data: Array.from(new Uint8Array(buffer)), + inputPath: sourceFilePath, stem, inputFormat: inputExtension, outputFormat: extension, - options: buildConversionOptions(), }, }); - const uint8Array = - result instanceof Uint8Array ? result : new Uint8Array(result); - const sequenceKey = stem; const sequence = (outputConversionSequences.current.get(sequenceKey) ?? 0) + 1; outputConversionSequences.current.set(sequenceKey, sequence); const outputName = `${stem}_${sequence}.${extension}`; - setConvertedFile( - new File([uint8Array as BlobPart], outputName, { - type: mimeTypes[convertedFormat][0] || `application/octet-stream`, - }), - ); + cleanupOldTempFile(convertedFilePath); + + setConvertedFilePath(outputTempPath); + setConvertedFileName(outputName); setConvertedFileFormat(convertedFormat); setConvertedSettingsKey(settingsKeyAtConversion); } catch (error) { @@ -406,20 +430,19 @@ function App() { }; const saveFile = async () => { - if (!convertedFile) return; + if (!convertedFilePath || !convertedFileName) return; try { - const filePath = await save({ - defaultPath: convertedFile.name, + const destinationPath = await save({ + defaultPath: convertedFileName, }); - if (!filePath) { + if (!destinationPath) { return; } - const buffer = await convertedFile.arrayBuffer(); - - await writeFile(filePath, new Uint8Array(buffer)); + // 追記・コピー処理(Tauri FSのcopyFileでパス指定転送) + await copyFile(convertedFilePath, destinationPath); } catch (error) { console.error(t("saveFailed"), error); } @@ -481,9 +504,7 @@ function App() { return; } setResizeWidth( - isVideoOutput - ? normalizeVideoDimension(value) - : clampDimension(value), + isVideoOutput ? normalizeVideoDimension(value) : clampDimension(value), ); }; @@ -494,9 +515,7 @@ function App() { return; } setResizeHeight( - isVideoOutput - ? normalizeVideoDimension(value) - : clampDimension(value), + isVideoOutput ? normalizeVideoDimension(value) : clampDimension(value), ); }; @@ -510,11 +529,6 @@ function App() { } }; - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - selectFile(event.dataTransfer.files[0]); - }; - return (
{t("language")} - + setLocale(event.target.value as typeof locale) + } + className="rounded border border-[#dfe5ef] bg-white px-2 py-1 text-xs text-[#40506a]" + > @@ -627,9 +647,7 @@ function App() { {t("sourceTitle")} -

- {t("sourceHint")} -

+

{t("sourceHint")}

@@ -650,9 +668,7 @@ function App() { max-[980px]:h-57.5 " - onClick={() => fileInput.current?.click()} - onDragOver={(event) => event.preventDefault()} - onDrop={handleDrop} + onClick={selectFileWithDialog} > - {sourceFile?.name ?? t("dropFile")} + {sourceFileName ?? t("dropFile")} - {sourceFile ? t("chooseAnother") : t("chooseFile")} + {sourceFilePath ? t("chooseAnother") : t("chooseFile")} - selectFile(event.target.files?.[0])} - /> - {error && (

{error} @@ -717,7 +725,7 @@ function App() {