diff --git a/src-tauri/src/conversion/error.rs b/src-tauri/src/conversion/error.rs index 63fe813..d7ea4fe 100644 --- a/src-tauri/src/conversion/error.rs +++ b/src-tauri/src/conversion/error.rs @@ -11,6 +11,7 @@ pub enum ErrorCode { ConversionFailed, OutputReadFailed, ProbeFailed, + ThumbnailFailed, } #[derive(Debug, Serialize)] diff --git a/src-tauri/src/conversion/ffmpeg.rs b/src-tauri/src/conversion/ffmpeg.rs index 3999d44..d6f10c4 100644 --- a/src-tauri/src/conversion/ffmpeg.rs +++ b/src-tauri/src/conversion/ffmpeg.rs @@ -65,6 +65,10 @@ fn build_default_args( args.extend(["-vf".into(), scale]); } + if matches!(output_format, FileFormat::Png | FileFormat::Jpeg) { + args.extend(["-frames:v".into(), "1".into()]); + } + if let Some(compression_level) = options.compression_level { args.extend(["-compression_level".into(), compression_level.to_string()]); } diff --git a/src-tauri/src/conversion/mod.rs b/src-tauri/src/conversion/mod.rs index ff3fa1c..d1e7302 100644 --- a/src-tauri/src/conversion/mod.rs +++ b/src-tauri/src/conversion/mod.rs @@ -15,6 +15,7 @@ use tauri_plugin_shell::ShellExt; pub use error::{ApiError, ConversionError, ErrorCode}; pub use types::{ConversionRequest, MediaDimensions, MediaProbeRequest}; +use types::FileFormat; pub async fn convert( app: &AppHandle, @@ -117,6 +118,55 @@ pub async fn probe_dimensions( parse_png_dimensions(&probe_data) } +pub async fn generate_thumbnail( + app: &AppHandle, + request: MediaProbeRequest, +) -> Result, ConversionError> { + let workspace = TempWorkspace::new()?; + let input_path = workspace + .path() + .join(format!("input.{}", request.input_format.extension())); + let is_gif = request.input_format == FileFormat::Gif; + let thumbnail_path = workspace + .path() + .join(if is_gif { "thumbnail.png" } else { "thumbnail.jpg" }); + + fs::write(&input_path, request.data) + .map_err(|_| ConversionError::new(ErrorCode::InputWriteFailed))?; + + let mut args = vec![ + "-y".to_string(), + "-i".to_string(), + input_path.to_string_lossy().into_owned(), + "-an".to_string(), + "-vf".to_string(), + "thumbnail=30,scale=iw*sar:ih,setsar=1,scale=640:640:force_original_aspect_ratio=decrease".to_string(), + "-frames:v".to_string(), + "1".to_string(), + "-update".to_string(), + "1".to_string(), + ]; + if !is_gif { + args.extend(["-q:v".to_string(), "4".to_string()]); + } + args.push(thumbnail_path.to_string_lossy().into_owned()); + + let output = app + .shell() + .sidecar("ffmpeg") + .map_err(|_| ConversionError::new(ErrorCode::FfmpegUnavailable))? + .args(args) + .output() + .await + .map_err(|_| ConversionError::new(ErrorCode::ThumbnailFailed))?; + + if !output.status.success() { + return Err(ConversionError::new(ErrorCode::ThumbnailFailed)); + } + + fs::read(&thumbnail_path).map_err(|_| ConversionError::new(ErrorCode::ThumbnailFailed)) +} + fn parse_png_dimensions(data: &[u8]) -> Result { const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6aca92..b1f0fdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,16 @@ async fn probe_media_dimensions( .map_err(ApiError::from) } +#[tauri::command] +async fn generate_thumbnail( + app: tauri::AppHandle, + request: MediaProbeRequest, +) -> Result, ApiError> { + conversion::generate_thumbnail(&app, request) + .await + .map_err(ApiError::from) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -52,7 +62,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ convert_file, probe_media_dimensions, - cancel_conversion + cancel_conversion, + generate_thumbnail ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index afaa12e..b097677 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { ImageOptions } from "./components/ImageOptions"; import { VideoOptions } from "./components/VideoOptions"; import { ResizeOptions } from "./components/ResizeOptions"; import { type Translation, useTranslation } from "./i18n"; +import { MediaPreview } from "./components/MediaPreview"; const MAX_DIMENSION = 16384; @@ -636,7 +637,8 @@ function App() { + + +
)} +
diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx new file mode 100644 index 0000000..8118cb5 --- /dev/null +++ b/src/components/MediaPreview.tsx @@ -0,0 +1,123 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useRef, useState } from "react"; +import { + IMAGE_FORMATS, + VIDEO_FORMATS, + type Format, + type ImageFormat, + type VideoFormat, + formatToExtension, +} from "../formats.ts"; + +type MediaPreviewProps = { + file: File | null; + format: Format | null; +}; + +export function MediaPreview({ file, format }: MediaPreviewProps) { + const containerRef = useRef(null); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [animatedUrl, setAnimatedUrl] = useState(null); + const [hovered, setHovered] = useState(false); + + const isGif = format === "GIF"; + + useEffect(() => { + const parent = containerRef.current?.parentElement; + if (!parent) return; + + const handleEnter = () => setHovered(true); + const handleLeave = () => setHovered(false); + parent.addEventListener("mouseenter", handleEnter); + parent.addEventListener("mouseleave", handleLeave); + return () => { + parent.removeEventListener("mouseenter", handleEnter); + parent.removeEventListener("mouseleave", handleLeave); + }; + }, []); + + useEffect(() => { + setThumbnailUrl(null); + if (!file || !format) return; + + let cancelled = false; + let objectUrl: string | null = null; + + const show = (blob: Blob) => { + if (cancelled) return; + objectUrl = URL.createObjectURL(blob); + setThumbnailUrl(objectUrl); + }; + + if ( + format === "GIF" || + VIDEO_FORMATS.includes(format as VideoFormat) + ) { + file + .arrayBuffer() + .then((buffer) => + invoke("generate_thumbnail", { + request: { + data: Array.from(new Uint8Array(buffer)), + inputFormat: formatToExtension(format), + }, + }), + ) + .then((result) => { + const bytes = + result instanceof Uint8Array ? result : new Uint8Array(result); + show( + new Blob([bytes as BlobPart], { + type: format === "GIF" ? "image/png" : "image/jpeg", + }), + ); + }) + .catch((error) => { + console.error("サムネイルの生成に失敗しました:", error); + }); + } else if (IMAGE_FORMATS.includes(format as ImageFormat)) { + show(file); + } + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [file, format]); + + useEffect(() => { + setAnimatedUrl(null); + if (!file || !isGif || !hovered) return; + + const objectUrl = URL.createObjectURL(file); + setAnimatedUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [file, isGif, hovered]); + + return ( +