From 5e1f0192de22c6daeba50ad5c2fa8f228b3892d0 Mon Sep 17 00:00:00 2001 From: faithia-anastasia <211831874+faithia-anastasia@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:56:46 +0900 Subject: [PATCH 1/3] feat: change data flow from vector to path --- src-tauri/capabilities/default.json | 2 + src-tauri/src/conversion/error.rs | 6 +- src-tauri/src/conversion/mod.rs | 114 ++++++++------- src-tauri/src/conversion/types.rs | 35 +++-- src-tauri/src/lib.rs | 2 +- src/App.tsx | 206 ++++++++++++++++------------ src/i18n.tsx | 14 +- 7 files changed, 213 insertions(+), 166 deletions(-) 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..6cfc216 100644 --- a/src-tauri/src/conversion/mod.rs +++ b/src-tauri/src/conversion/mod.rs @@ -4,7 +4,7 @@ mod types; use std::{ fs, - path::{Path, PathBuf}, + path::PathBuf, sync::atomic::{AtomicBool, Ordering}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, @@ -20,19 +20,32 @@ pub async fn convert( app: &AppHandle, request: ConversionRequest, cancel_flag: Arc, -) -> Result, ConversionError> { - let workspace = TempWorkspace::new()?; - - let input_path = workspace - .path() - .join(format!("input.{}", request.input_format.extension())); - let output_path = workspace - .path() - .join(format!("output.{}", request.output_format.extension())); - - fs::write(&input_path, request.data) - .map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; +) -> Result { + // 1. 入力ファイルの存在チェック(絶対パス) + let input_path = PathBuf::from(&request.input_path); + if !input_path.exists() { + return Err(ConversionError::new(ErrorCode::InputFileNotFound)); + } + // 2. 一時出力先ディレクトリの準備 + 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(); + + // 出力先一時ファイルの絶対パス + let output_path = temp_dir.join(format!( + "{}_{}.{}", + request.stem, + timestamp, + request.output_format.extension() + )); + + // 3. FFmpeg 引数の組み立て (絶対パス同士で指定) let args = ffmpeg::build_args( &input_path, &output_path, @@ -42,7 +55,7 @@ pub async fn convert( ) .map_err(|_| ConversionError::new(ErrorCode::InvalidOptions))?; - // 1. .output() ではなく .spawn() を使用してプロセスを起動する + // 4. Sidecar (FFmpeg) の起動 let (mut rx, child) = app .shell() .sidecar("ffmpeg") @@ -51,48 +64,60 @@ pub async fn convert( .spawn() .map_err(|_| ConversionError::new(ErrorCode::FfmpegStartFailed))?; - // 2. FFmpeg の実行完了、またはキャンセルフラグの変更を非同期にループ監視する + // 5. キャンセル監視付きの非同期実行ループ loop { - // フロントエンドから cancel_conversion が呼ばれたかをチェック if cancel_flag.load(Ordering::Relaxed) { - // FFmpeg プロセスを強制終了する let _ = child.kill(); + // キャンセル時は一時ファイルを削除 + let _ = fs::remove_file(&output_path); 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) { + let _ = fs::remove_file(&output_path); return Err(ConversionError::new(ErrorCode::ConversionFailed)); } - // 正常終了したためループを抜ける break; } } - fs::read(&output_path).map_err(|_| ConversionError::new(ErrorCode::OutputReadFailed)) + // 出力ファイルが正常に生成されたことを確認してパス文字列を返す + if !output_path.exists() { + return Err(ConversionError::new(ErrorCode::OutputFileNotGenerated)); + } + + Ok(output_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)); + } - fs::write(&input_path, request.data) - .map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; + 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(); + + let probe_path = 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(), @@ -108,12 +133,16 @@ pub async fn probe_dimensions( .map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?; if !output.status.success() { + let _ = fs::remove_file(&probe_path); return Err(ConversionError::new(ErrorCode::ProbeFailed)); } let probe_data = fs::read(&probe_path).map_err(|_| ConversionError::new(ErrorCode::ProbeFailed))?; + // 解析後、一時プローブ画像は不要のため即時削除 + let _ = fs::remove_file(&probe_path); + parse_png_dimensions(&probe_data) } @@ -133,34 +162,3 @@ fn parse_png_dimensions(data: &[u8]) -> Result Ok(MediaDimensions { width, height }) } - -struct TempWorkspace { - path: PathBuf, -} - -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 }) - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempWorkspace { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&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..2543f94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ async fn convert_file( app: tauri::AppHandle, state: State<'_, AppState>, request: ConversionRequest, -) -> Result, ApiError> { +) -> Result { // 変換開始時にキャンセルフラグを「false(未キャンセル)」にリセット state.cancel_flag.store(false, Ordering::Relaxed); diff --git a/src/App.tsx b/src/App.tsx index afaa12e..0769c8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ 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"; @@ -49,12 +50,14 @@ function apiErrorMessage(error: unknown, t: Translation): string { 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", + input_file_not_found: "error_input_file_not_found", + temp_dir_creation_failed: "error_temp_dir_creation_failed", + timestamp_fetch_failed: "error_timestamp_fetch_failed", + output_file_not_generated: "error_output_file_not_generated", } as const; if (typeof code === "string" && code in errorKeys) return t(errorKeys[code as keyof typeof errorKeys]); return t("error_unexpected"); @@ -64,24 +67,43 @@ function tryParseError(error: string): unknown { try { return JSON.parse(error); } catch { return error; } } +// パス文字列から拡張子を取得するヘルパー関数 +function getExtensionFromPath(filePath: string): string { + const parts = filePath.split("."); + return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? "") : ""; +} + +// 拡張子からサポートされている Format を判定するヘルパー関数 +function detectFormatFromPath(filePath: string): Format | undefined { + const ext = getExtensionFromPath(filePath); + const matchedEntry = Object.entries(mimeTypes).find(([_, extensions]) => + extensions.some( + (e) => e.toLowerCase().includes(ext) || ext.includes(e.toLowerCase()), + ), + ); + return matchedEntry ? (matchedEntry[0] as Format) : undefined; +} + function App() { const { locale, setLocale, t } = useTranslation(); - const [sourceFile, setSourceFile] = useState(null); - const [convertedFile, setConvertedFile] = 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,7 +143,6 @@ function App() { pcmBitDepth: 16, }); - const fileInput = useRef(null); const isVideoOutput = VIDEO_FORMATS.includes( convertedFormat as VideoFormat, @@ -149,7 +170,7 @@ function App() { audioCompression, }); const conversionSettingsChanged = - convertedFile !== null && convertedSettingsKey !== conversionSettingsKey; + convertedFilePath !== null && convertedFileName !== null && convertedSettingsKey !== conversionSettingsKey; const availableOutputFormats = useMemo(() => { if (!sourceFormat) { @@ -171,20 +192,25 @@ 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; + // ファイルパスを受け取って内部状態を更新し、メディア情報の計測を行う共通処理 + const processSelectedFilePath = useCallback(async (filePath: string) => { + const fileName = filePath.split(/[/\\]/).pop() ?? filePath; + const detectedFormat = detectFormatFromPath(filePath); if (!detectedFormat) { - setError(t("unsupportedFormat", { type: file.type, formats: SUPPORTED_FORMATS.join(", ") })); + setError(t("unsupportedFormat", { type: getExtensionFromPath(filePath), formats: SUPPORTED_FORMATS.join(", ") })); return; } - setSourceFile(file); - setConvertedFile(null); + console.log( + `fileName: ${fileName},\nfilePath: ${filePath},\ndetectedFormat: ${detectedFormat}`, + ); + setSourceFileName(fileName); + setSourceFilePath(filePath); + setSourceFileType(detectedFormat); + + setConvertedFilePath(null); + setConvertedFileName(null); setConvertedFileFormat(null); setConvertedSettingsKey(null); setError(null); @@ -214,12 +240,11 @@ function App() { setIsProbingDimensions(true); try { - const buffer = await file.arrayBuffer(); const dimensions = await invoke( "probe_media_dimensions", { request: { - data: Array.from(new Uint8Array(buffer)), + inputPath: filePath, inputFormat: formatToExtension(detectedFormat), }, }, @@ -240,8 +265,46 @@ function App() { setIsProbingDimensions(false); } } + }, []); + + // Tauri 公式のネイティブファイル選択ダイアログを開く + const selectFileWithDialog = async () => { + try { + const selected = await open({ + multiple: false, + directory: 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 +355,6 @@ function App() { }; default: if (isAudioFormat(convertedFormat)) { - // sampleRateやchannelsが 0 (Auto) の場合は undefined にし、Rust/FFmpeg側で引数を省略できるようにする const commonAudio = { sampleRate: audioCompression.sampleRate > 0 @@ -357,7 +419,7 @@ function App() { }; const convertFile = async () => { - if (!sourceFile || !sourceFormat) return; + if (!sourceFilePath || !sourceFileName || !sourceFormat) return; const settingsKeyAtConversion = conversionSettingsKey; setIsConverting(true); @@ -366,36 +428,28 @@ 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`, - }), - ); + + setConvertedFilePath(outputTempPath); + setConvertedFileName(outputName); setConvertedFileFormat(convertedFormat); setConvertedSettingsKey(settingsKeyAtConversion); } catch (error) { @@ -406,20 +460,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); } @@ -427,6 +480,8 @@ function App() { const handleFormatChange = (newFormat: Format) => { setConvertedFormat(newFormat); + setConvertedFilePath(null); + setConvertedFileName(null); if (mediaDimensions && VIDEO_FORMATS.includes(newFormat as VideoFormat)) { setResizeWidth(normalizeVideoDimension(resizeWidth)); @@ -510,11 +565,6 @@ function App() { } }; - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - selectFile(event.dataTransfer.files[0]); - }; - return (
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 +757,7 @@ function App() { setLocale(event.target.value as typeof locale)} className="rounded border border-[#dfe5ef] bg-white px-2 py-1 text-xs text-[#40506a]"> + @@ -677,9 +704,7 @@ function App() { {t("sourceTitle")} -

- {t("sourceHint")} -

+

{t("sourceHint")}

@@ -891,9 +916,7 @@ function App() { {t("outputTitle")} -

- {t("outputHint")} -

+

{t("outputHint")}

@@ -1059,9 +1082,7 @@ function App() { /> ) : (

- {sourceFilePath - ? t("noOptions") - : t("chooseForOptions")} + {sourceFilePath ? t("noOptions") : t("chooseForOptions")}

)} From aa9f6c8bece4fd323995216eb53a364428b970e9 Mon Sep 17 00:00:00 2001 From: faithia-anastasia <211831874+faithia-anastasia@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:31:01 +0900 Subject: [PATCH 3/3] refactor: extract utility functions from App.tsx into modules --- src/App.tsx | 73 +++++------------------------------------- src/utils/dimension.ts | 12 +++++++ src/utils/error.ts | 34 ++++++++++++++++++++ src/utils/path.ts | 18 +++++++++++ 4 files changed, 72 insertions(+), 65 deletions(-) create mode 100644 src/utils/dimension.ts create mode 100644 src/utils/error.ts create mode 100644 src/utils/path.ts diff --git a/src/App.tsx b/src/App.tsx index 566dd15..a163d18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,75 +20,18 @@ import { type ImageFormat, isAudioFormat, type MediaDimensions, - mimeTypes, SUPPORTED_FORMATS, VIDEO_FORMATS, type VideoFormat, } from "./formats.ts"; -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", - ffmpeg_unavailable: "error_ffmpeg_unavailable", - ffmpeg_start_failed: "error_ffmpeg_start_failed", - conversion_failed: "error_conversion_failed", - probe_failed: "error_probe_failed", - input_file_not_found: "error_input_file_not_found", - temp_dir_creation_failed: "error_temp_dir_creation_failed", - timestamp_fetch_failed: "error_timestamp_fetch_failed", - output_file_not_generated: "error_output_file_not_generated", - } 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 getExtensionFromPath(filePath: string): string { - const parts = filePath.split("."); - return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? "") : ""; -} - -// 拡張子からサポートされている Format を判定するヘルパー関数 -function detectFormatFromPath(filePath: string): Format | undefined { - const ext = getExtensionFromPath(filePath); - const matchedEntry = Object.entries(mimeTypes).find(([_, extensions]) => - extensions.some( - (e) => e.toLowerCase().includes(ext) || ext.includes(e.toLowerCase()), - ), - ); - return matchedEntry ? (matchedEntry[0] as Format) : undefined; -} +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(); diff --git a/src/utils/dimension.ts b/src/utils/dimension.ts new file mode 100644 index 0000000..c260161 --- /dev/null +++ b/src/utils/dimension.ts @@ -0,0 +1,12 @@ +export const MAX_DIMENSION = 16384; + +export function clampDimension(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.min(MAX_DIMENSION, Math.max(1, Math.round(value))); +} + +export function normalizeVideoDimension(value: number): number { + const clamped = clampDimension(value); + if (clamped % 2 === 0) return clamped; + return Math.min(MAX_DIMENSION, clamped + 1); +} diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..175a43b --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,34 @@ +import type { Translation } from "../i18n"; + +export 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", + ffmpeg_unavailable: "error_ffmpeg_unavailable", + ffmpeg_start_failed: "error_ffmpeg_start_failed", + conversion_failed: "error_conversion_failed", + probe_failed: "error_probe_failed", + input_file_not_found: "error_input_file_not_found", + temp_dir_creation_failed: "error_temp_dir_creation_failed", + timestamp_fetch_failed: "error_timestamp_fetch_failed", + output_file_not_generated: "error_output_file_not_generated", + } 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; + } +} diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 0000000..66ac860 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,18 @@ +import { type Format, mimeTypes } from "../formats"; + +// パス文字列から拡張子を取得するヘルパー関数 +export function getExtensionFromPath(filePath: string): string { + const parts = filePath.split("."); + return parts.length > 1 ? (parts.pop()?.toLowerCase() ?? "") : ""; +} + +// 拡張子からサポートされている Format を判定するヘルパー関数 +export function detectFormatFromPath(filePath: string): Format | undefined { + const ext = getExtensionFromPath(filePath); + const matchedEntry = Object.entries(mimeTypes).find(([_, extensions]) => + extensions.some( + (e) => e.toLowerCase().includes(ext) || ext.includes(e.toLowerCase()), + ), + ); + return matchedEntry ? (matchedEntry[0] as Format) : undefined; +}