Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/src/conversion/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub enum ErrorCode {
ConversionFailed,
OutputReadFailed,
ProbeFailed,
ThumbnailFailed,
}

#[derive(Debug, Serialize)]
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/conversion/ffmpeg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
}
Expand Down
50 changes: 50 additions & 0 deletions src-tauri/src/conversion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -117,6 +118,55 @@ pub async fn probe_dimensions(
parse_png_dimensions(&probe_data)
}

pub async fn generate_thumbnail(
app: &AppHandle,
request: MediaProbeRequest,
) -> Result<Vec<u8>, 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<MediaDimensions, ConversionError> {
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";

Expand Down
13 changes: 12 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>, ApiError> {
conversion::generate_thumbnail(&app, request)
.await
.map_err(ApiError::from)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
Expand All @@ -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");
Expand Down
14 changes: 12 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -636,7 +637,8 @@ function App() {
<button
type="button"
className="
flex h-70 w-full cursor-pointer
relative flex h-70 w-full cursor-pointer
overflow-hidden
flex-col items-center justify-center
rounded-[14px]
border-[1.5px] border-dashed
Expand All @@ -654,6 +656,9 @@ function App() {
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<MediaPreview file={sourceFile} format={sourceFormat} />

<span className="relative flex w-full flex-col items-center">
<span
className="
grid size-12 place-items-center
Expand Down Expand Up @@ -682,6 +687,7 @@ function App() {
<span className="text-xs">
{sourceFile ? t("chooseAnother") : t("chooseFile")}
</span>
</span>
</button>

<input
Expand Down Expand Up @@ -859,12 +865,15 @@ function App() {

<div
className={[
"flex h-70 flex-col items-center justify-center rounded-[14px] border bg-[#fcfdff] p-5.5 text-center text-[#8e9aab] max-[980px]:h-57.5",
"relative flex h-70 flex-col items-center justify-center overflow-hidden rounded-[14px] border bg-[#fcfdff] p-5.5 text-center text-[#8e9aab] max-[980px]:h-57.5",
convertedFile
? "border-[#dce3ff] bg-[#fbfcff]"
: "border-[#edf0f5]",
].join(" ")}
>
<MediaPreview file={convertedFile} format={convertedFormat} />

<div className="relative flex w-full flex-col items-center">
<span
className="
grid size-12 place-items-center
Expand Down Expand Up @@ -921,6 +930,7 @@ function App() {
{t("saveFile")}
</button>
)}
</div>
</div>
</div>
</section>
Expand Down
123 changes: 123 additions & 0 deletions src/components/MediaPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [animatedUrl, setAnimatedUrl] = useState<string | null>(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<number[] | Uint8Array>("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 (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
>
{thumbnailUrl && (
<>
<img
src={thumbnailUrl}
alt=""
className="absolute inset-0 size-full object-contain"
onError={() => setThumbnailUrl(null)}
/>
{animatedUrl && (
<img
src={animatedUrl}
alt=""
className="absolute inset-0 size-full object-contain"
/>
)}
<div className="absolute inset-0 bg-white/60" />
</>
)}
</div>
);
}