From 63b4440203d63594cf8513e662f2b329a6244a00 Mon Sep 17 00:00:00 2001 From: SotaTamura Date: Tue, 11 Aug 2026 11:01:11 +0900 Subject: [PATCH 1/2] feat: video conversion --- .gitignore | 1 + package-lock.json | 42 ++++++++++++++++ package.json | 3 ++ public/tauri.svg | 6 --- public/vite.svg | 1 - src-tauri/src/lib.rs | 51 ++++++++++++++++++-- src/App.tsx | 111 +++++++++++++++++++++++++++---------------- vite.config.ts | 14 ++++++ 8 files changed, 177 insertions(+), 52 deletions(-) delete mode 100644 public/tauri.svg delete mode 100644 public/vite.svg 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/package-lock.json b/package-lock.json index 9dc01f7..1f258f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "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", @@ -725,6 +728,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", diff --git a/package.json b/package.json index 0c0fada..6da4768 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "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", 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/src/lib.rs b/src-tauri/src/lib.rs index aa18a96..aacb223 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,50 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use std::process::Command; + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn convert_file(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 ffmpeg_cmd = if Command::new("ffmpeg").arg("-version").output().is_ok() { + "ffmpeg" + } else if std::path::Path::new("/opt/homebrew/bin/ffmpeg").exists() { + "/opt/homebrew/bin/ffmpeg" + } else if std::path::Path::new("/usr/local/bin/ffmpeg").exists() { + "/usr/local/bin/ffmpeg" + } else { + let _ = std::fs::remove_file(&in_path); + return Err("システムに FFmpeg が見つかりませんでした。FFmpeg をインストールしてください。".to_string()); + }; + + let mut cmd = Command::new(ffmpeg_cmd); + cmd.args(["-y", "-i"]).arg(&in_path); + + let status = cmd + .arg(&out_path) + .status() + .map_err(|e| { + let _ = std::fs::remove_file(&in_path); + format!("FFmpeg の実行に失敗しました: {}", e) + })?; + + let _ = std::fs::remove_file(&in_path); + + if !status.success() { + let _ = std::fs::remove_file(&out_path); + return Err("FFmpeg による変換処理に失敗しました。".to_string()); + } + + 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)] @@ -10,7 +53,7 @@ pub fn run() { .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/App.tsx b/src/App.tsx index 559e312..d3177a2 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 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 = { +const mimeTypes: Record = { PNG: "image/png", JPEG: "image/jpeg", WebP: "image/webp", + MP4: "video/mp4", + WebM: "video/webm", + AVI: "video/x-msvideo", + MOV: "video/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] === 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,10 @@ function App() { const selectFile = (file?: File) => { if (!file) return; - if (!file.type.startsWith("image/")) { - setError("仮配線では画像ファイル(PNG・JPEG・WebP)のみ変換できます。"); + if (!Object.values(mimeTypes).includes(file.type)) { + setError( + `${file.type} はサポートされていない形式です。PNG、JPEG、WebP、MP4、WebM、AVI、MOV のいずれかを選択してください。`, + ); return; } @@ -70,22 +91,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] || + (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 +143,8 @@ function App() { } }; - const handleFormatChange = (newFormat: string) => { - setFormat(newFormat); + const handleFormatChange = (newFormat: ImageFormat | VideoFormat) => { + setConvertedFormat(newFormat); setConvertedFile(null); }; @@ -160,7 +192,6 @@ function App() { > H - 変換博士 @@ -282,17 +313,13 @@ function App() { {sourceFile ? "別のファイルを選択" : "または、クリックして選択"} - - - 仮配線の対応形式:PNG、JPEG、WebP - selectFile(event.target.files?.[0])} /> @@ -320,8 +347,12 @@ function App() { {/* Arrow */} @@ -350,7 +382,7 @@ function App() { max-[980px]:mb-8.25 max-[980px]:rotate-90 " - aria-label={`${format} に変換`} + aria-label={`${convertedFormat} に変換`} > @@ -374,7 +406,7 @@ function App() { max-[980px]:bottom-0 " > - {format} に変換 + {convertedFormat} に変換