diff --git a/.gitignore b/.gitignore index a547bf3..bd6417a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.agents/* \ No newline at end of file diff --git a/README.md b/README.md index 086a62e..91f4deb 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,23 @@ ## ディレクトリ構成 -| パス | 内容 | -| ---------------------- | ------------------------------------------------------------- | -| `dist` | ビルド成果物の出力先ディレクトリ。`npm run build`で生成される | -| `node_modules` | npmの依存パッケージ。`npm install`で生成される | -| `public` | ビルド後にルートに置かれる。画像など | -| `src/App.css` | スタイルシート | -| `src/App.tsx` | フロントエンドの本体 | -| `src-tauri/icons` | アプリケーションアイコン | -| `src-tauri/src/lib.rs` | 変換処理のロジック | -| `.gitignore` | Gitで管理しないファイルの設定 | -| `package-lock.json` | npmの依存パッケージのバージョンを固定するためのファイル | -| `package.json` | npmの設定ファイル | -| `README.md` | この文章 | +| パス | 内容 | +| --------------------------- | ------------------------------------------------------------- | +| `dist` | ビルド成果物の出力先ディレクトリ。`npm run build`で生成される | +| `node_modules` | npmの依存パッケージ。`npm install`で生成される | +| `public` | ビルド後にルートに置かれる。画像など | +| `src/App.css` | スタイルシート | +| `src/App.tsx` | フロントエンドの本体 | +| `src-tauri/binaries` | FFmpegのバイナリファイル。sidecarで使用されている | +| `src-tauri/icons` | アプリケーションアイコン | +| `src-tauri/src/lib.rs` | 変換処理のロジック | +| `src-tauri/.gitignore` | Gitで管理しないファイルの設定(バックエンド) | +| `src-tauri/tauri.conf.json` | Tauriの設定ファイル | +| `src-tauri/Cargo.toml` | Rustの設定ファイル | +| `.gitignore` | Gitで管理しないファイルの設定 | +| `package-lock.json` | npmの依存パッケージのバージョンを固定するためのファイル | +| `package.json` | npmの設定ファイル | +| `README.md` | この文章 | ## インストール @@ -37,11 +41,6 @@ winget install --id Rustlang.Rustup ``` -3. FFmpegのインストール - ```bash - winget install -e --id Gyan.FFmpeg - ``` - ### MacOSの場合 1. Rustのインストール @@ -60,20 +59,57 @@ source ~/.zshrc ``` -3. FFmpegのインストール +## FFmpegのバイナリファイルの設置 + +1. `src-tauri/binaries`ディレクトリを作成 + +2. Linux用のバイナリファイルの用意 + 1. https://github.com/BtbN/FFmpeg-Builds/releases から`ffmpeg-n8.1-latest-linux64-gpl-8.1.tar.xz`をダウンロード + 2. 解凍して出てくる`bin/ffmpeg`を`src-tauri/binaries`配下に設置 + 3. `fmpeg-x86_64-unknown-linux-gnu`にリネームする + +3. Windows用のバイナリファイルの用意 + 1. https://www.gyan.dev/ffmpeg/builds/ から`ffmpeg-release-full.7z`をダウンロード + 2. 解凍して出てくる`bin/ffmpeg.exe`を`src-tauri/binaries`配下に設置 + 3. `ffmpeg-x86_64-pc-windows-msvc.exe`にリネームする + +4. MacOS(Intel)用のバイナリファイルの用意 + 1. https://evermeet.cx/ffmpeg/ から`ffmpeg-9.0.1.7z`をダウンロード + 2. 解凍して出てくる`ffmpeg`を`src-tauri/binaries`配下に設置 + 3. `ffmpeg-x86_64-apple-darwin`にリネームする + +5. MacOS(Apple Silicon)用のバイナリファイルの用意 + 1. https://www.osxexperts.net/ で"Download ffmpeg 9.0 (Apple Silicon)"をクリックして`ffmpeg9arm.zip`をダウンロード + 2. 解凍して出てくる`ffmpeg`を`src-tauri/binaries`配下に設置 + 3. `ffmpeg-aarch64-apple-darwin`にリネームする + +6. バイナリファイルの実行権限を付与(初回のみ) + ```bash - brew install ffmpeg-full + chmod +x src-tauri/binaries/ffmpeg-* ``` ## 開発 -- 依存パッケージのインストール +- 依存パッケージのインストール(`package.json`が更新されたときに実行) ```bash npm install ``` -- 開発環境でアプリケーションを起動(ctrl + C / ⌃Cで終了) +- 新たなパッケージのインストール + + ```bash + npm install <パッケージ名> + ``` + +- tauriのプラグインのインストール + + ```bash + npm run tauri add <プラグイン名> + ``` + +- 開発環境でアプリケーションを起動(`ctrl + C` / `⌃C`で終了) ```bash npm run tauri dev @@ -85,7 +121,20 @@ npm run tauri build ``` -- FFmpegでファイル形式を変換する例(プロジェクトのルートにinput.jpgを置く) +- FFmpegのインストール(バイナリファイルを設置するため、インストールしなくてもアプリケーションは動作する) + - Windowsの場合 + + ```bash + winget install -e --id Gyan.FFmpeg + ``` + + - MacOSの場合 + + ```bash + brew install ffmpeg-full + ``` + +- FFmpegでファイル形式を変換する例(プロジェクトのルートに`input.jpg`を置く) ```bash ffmpeg -i input.jpg output.png ``` diff --git a/package-lock.json b/package-lock.json index 9dc01f7..3da5d1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "henkanhakase", "version": "0.1.0", "dependencies": { + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2.3.5", "browser-image-compression": "^2.0.2", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -725,6 +729,45 @@ "node": ">=18" } }, + "node_modules/@ffmpeg/core": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz", + "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1613,6 +1656,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 0c0fada..591dd26 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,15 @@ "tauri": "tauri" }, "dependencies": { + "@ffmpeg/core": "^0.12.10", + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2.3.5", "browser-image-compression": "^2.0.2", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..fe7c329 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -5,3 +5,5 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +/binaries/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2411fd0..a7a22d0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -854,6 +854,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1428,6 +1437,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-opener", + "tauri-plugin-shell", ] [[package]] @@ -2306,6 +2316,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -3039,12 +3059,44 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3488,6 +3540,27 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d27dd7..26509d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" +tauri-plugin-shell = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3b96e47..1f7927c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "opener:default", "dialog:default", "fs:default", - "fs:allow-write-file" + "fs:allow-write-file", + "shell:default" ] } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa18a96..da875b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,16 +1,61 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use tauri_plugin_shell::ShellExt; + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +async fn convert_file( + app: tauri::AppHandle, + data: Vec, + stem: String, + format: String, +) -> Result, String> { + let temp_dir = std::env::temp_dir(); + let in_path = temp_dir.join(format!("{}.tmp", stem)); + let out_path = temp_dir.join(format!("{}.{}", stem, format.to_lowercase())); + + std::fs::write(&in_path, data) + .map_err(|e| format!("入力ファイルの作成に失敗しました: {}", e))?; + + let output = app + .shell() + .sidecar("ffmpeg") + .map_err(|e| format!("FFmpeg Sidecar の初期化に失敗しました: {}", e))? + .args([ + "-i", + in_path.to_str().unwrap_or_default(), + out_path.to_str().unwrap_or_default(), + ]) + .output() + .await + .map_err(|e| { + let _ = std::fs::remove_file(&in_path); + format!("FFmpeg の実行に失敗しました: {}", e) + })?; + + let _ = std::fs::remove_file(&in_path); + + if !output.status.success() { + let _ = std::fs::remove_file(&out_path); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("FFmpeg による変換処理に失敗しました: {}", stderr)); + } + + let output_data = std::fs::read(&out_path).map_err(|e| { + let _ = std::fs::remove_file(&out_path); + format!("出力ファイルの読み込みに失敗しました: {}", e) + })?; + + let _ = std::fs::remove_file(&out_path); + + Ok(output_data) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![convert_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a5dc06a..e5413e4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,7 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "externalBin": ["binaries/ffmpeg"] } } diff --git a/src/App.tsx b/src/App.tsx index 559e312..87d95c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,22 @@ -import { useRef, useState } from "react"; -import imageCompression from "browser-image-compression"; +import { useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { save } from "@tauri-apps/plugin-dialog"; import { writeFile } from "@tauri-apps/plugin-fs"; -const formats = ["PNG", "JPEG", "WebP"]; - -const mimeTypes: Record = { - PNG: "image/png", - JPEG: "image/jpeg", - WebP: "image/webp", +const IMAGE_FORMATS = ["PNG", "JPEG", "WebP"] as const; +const VIDEO_FORMATS = ["MP4", "WebM", "AVI", "MOV"] as const; +type ImageFormat = (typeof IMAGE_FORMATS)[number]; +type VideoFormat = (typeof VIDEO_FORMATS)[number]; +type Format = ImageFormat | VideoFormat; + +const mimeTypes: Record = { + PNG: ["image/png", "image/x-png"], + JPEG: ["image/jpeg", "image/pjpeg", "image/jpg"], + WebP: ["image/webp"], + MP4: ["video/mp4"], + WebM: ["video/webm"], + AVI: ["video/x-msvideo", "video/avi", "video/msvideo", "video/vnd.avi"], + MOV: ["video/quicktime", "video/mov", "video/x-quicktime"], }; function UploadIcon() { @@ -41,10 +49,21 @@ function FileIcon() { } function App() { - const [format, setFormat] = useState("PNG"); - const [detailsOpen, setDetailsOpen] = useState(false); const [sourceFile, setSourceFile] = useState(null); const [convertedFile, setConvertedFile] = useState(null); + const sourceFormat = useMemo( + () => + sourceFile + ? (Object.keys(mimeTypes).find((format) => + mimeTypes[format as Format].includes(sourceFile.type), + ) as Format) + : null, + [sourceFile], + ); + const [convertedFormat, setConvertedFormat] = useState< + ImageFormat | VideoFormat + >("PNG"); + const [detailsOpen, setDetailsOpen] = useState(false); const [isConverting, setIsConverting] = useState(false); const [error, setError] = useState(null); @@ -53,8 +72,11 @@ function App() { const selectFile = (file?: File) => { if (!file) return; - if (!file.type.startsWith("image/")) { - setError("仮配線では画像ファイル(PNG・JPEG・WebP)のみ変換できます。"); + console.log(`input file type: ${file.type}`); + if (!Object.values(mimeTypes).flat().includes(file.type)) { + setError( + `${file.type} はサポートされていない形式です。PNG、JPEG、WebP、MP4、WebM、AVI、MOV のいずれかを選択してください。`, + ); return; } @@ -70,22 +92,33 @@ function App() { setError(null); try { - const converted = await imageCompression(sourceFile, { - fileType: mimeTypes[format], - useWebWorker: true, - initialQuality: 0.92, - }); - - const extension = format === "JPEG" ? "jpg" : format.toLowerCase(); + const extension = + convertedFormat === "JPEG" ? "jpg" : convertedFormat.toLowerCase(); const stem = sourceFile.name.replace(/\.[^.]+$/, ""); - const outputFile = new File([converted], `${stem}.${extension}`, { - type: mimeTypes[format], + const buffer = await sourceFile.arrayBuffer(); + const result = await invoke("convert_file", { + data: Array.from(new Uint8Array(buffer)), + stem, + format: extension, }); - setConvertedFile(outputFile); - } catch { - setError("変換に失敗しました。別の画像ファイルでお試しください。"); + const uint8Array = + result instanceof Uint8Array ? result : new Uint8Array(result); + + setConvertedFile( + new File([uint8Array as BlobPart], `${stem}.${extension}`, { + type: + mimeTypes[convertedFormat][0] || + (IMAGE_FORMATS.includes(convertedFormat as ImageFormat) + ? `image/${extension}` + : `video/${extension}`), + }), + ); + } catch (error) { + setError( + `Error during conversion: ${error instanceof Error ? error.message : String(error)}`, + ); } finally { setIsConverting(false); } @@ -111,8 +144,8 @@ function App() { } }; - const handleFormatChange = (newFormat: string) => { - setFormat(newFormat); + const handleFormatChange = (newFormat: ImageFormat | VideoFormat) => { + setConvertedFormat(newFormat); setConvertedFile(null); }; @@ -160,7 +193,6 @@ function App() { > H - 変換博士 @@ -282,17 +314,13 @@ function App() { {sourceFile ? "別のファイルを選択" : "または、クリックして選択"} - - - 仮配線の対応形式:PNG、JPEG、WebP - selectFile(event.target.files?.[0])} /> @@ -320,8 +348,12 @@ function App() { {/* Arrow */} @@ -350,7 +383,7 @@ function App() { max-[980px]:mb-8.25 max-[980px]:rotate-90 " - aria-label={`${format} に変換`} + aria-label={`${convertedFormat} に変換`} > @@ -374,7 +407,7 @@ function App() { max-[980px]:bottom-0 " > - {format} に変換 + {convertedFormat} に変換