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} に変換