diff --git a/src/App.tsx b/src/App.tsx index afaa12e..f9083bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState } from "react"; import ArrowIcon from "./assets/arrow.svg"; import FileIcon from "./assets/file.svg"; import UploadIcon from "./assets/upload.svg"; +import { FormatDropdown } from "./components/FormatDropdown"; import { IMAGE_FORMATS, VIDEO_FORMATS, @@ -41,11 +42,12 @@ function normalizeVideoDimension(value: number): number { function apiErrorMessage(error: unknown, t: Translation): string { const value = typeof error === "string" ? tryParseError(error) : error; - const code = typeof value === "string" - ? value - : typeof value === "object" && value !== null && "code" in value - ? (value as { code: unknown }).code - : null; + const code = + typeof value === "string" + ? value + : typeof value === "object" && value !== null && "code" in value + ? (value as { code: unknown }).code + : null; const errorKeys = { conversion_cancelled: "error_conversion_cancelled", invalid_options: "error_invalid_options", @@ -56,20 +58,26 @@ function apiErrorMessage(error: unknown, t: Translation): string { output_read_failed: "error_output_read_failed", probe_failed: "error_probe_failed", } as const; - if (typeof code === "string" && code in errorKeys) return t(errorKeys[code as keyof typeof errorKeys]); + if (typeof code === "string" && code in errorKeys) + return t(errorKeys[code as keyof typeof errorKeys]); return t("error_unexpected"); } function tryParseError(error: string): unknown { - try { return JSON.parse(error); } catch { return error; } + try { + return JSON.parse(error); + } catch { + return error; + } } function App() { const { locale, setLocale, t } = useTranslation(); const [sourceFile, setSourceFile] = useState(null); const [convertedFile, setConvertedFile] = useState(null); - const [convertedFileFormat, setConvertedFileFormat] = - useState(null); + const [convertedFileFormat, setConvertedFileFormat] = useState( + null, + ); const [convertedSettingsKey, setConvertedSettingsKey] = useState< string | null >(null); @@ -123,21 +131,20 @@ function App() { const fileInput = useRef(null); - const isVideoOutput = VIDEO_FORMATS.includes( - convertedFormat as VideoFormat, - ); + const isVideoOutput = VIDEO_FORMATS.includes(convertedFormat as VideoFormat); // 詳細パネルの開閉ではなく、実際に変換結果へ影響する設定だけを比較する。 const conversionSettingsKey = JSON.stringify({ convertedFormat, - resize: !isAudioFormat(convertedFormat) && mediaDimensions - ? { - width: resizeWidth, - height: resizeHeight, - aspectRatioLocked, - antiAliasing, - } - : null, + resize: + !isAudioFormat(convertedFormat) && mediaDimensions + ? { + width: resizeWidth, + height: resizeHeight, + aspectRatioLocked, + antiAliasing, + } + : null, pngCompressionLevel, jpegQV, webpQV, @@ -179,7 +186,12 @@ function App() { ) as Format | undefined; if (!detectedFormat) { - setError(t("unsupportedFormat", { type: file.type, formats: SUPPORTED_FORMATS.join(", ") })); + setError( + t("unsupportedFormat", { + type: file.type, + formats: SUPPORTED_FORMATS.join(", "), + }), + ); return; } @@ -481,9 +493,7 @@ function App() { return; } setResizeWidth( - isVideoOutput - ? normalizeVideoDimension(value) - : clampDimension(value), + isVideoOutput ? normalizeVideoDimension(value) : clampDimension(value), ); }; @@ -494,9 +504,7 @@ function App() { return; } setResizeHeight( - isVideoOutput - ? normalizeVideoDimension(value) - : clampDimension(value), + isVideoOutput ? normalizeVideoDimension(value) : clampDimension(value), ); }; @@ -571,7 +579,13 @@ function App() { {isConverting ? t("converting") : t("waiting")} - + onChange={handleFormatChange} + /> -

- {t("outputHint")} -

+

{t("outputHint")}

@@ -1021,9 +1010,7 @@ function App() { /> ) : (

- {sourceFile - ? t("noOptions") - : t("chooseForOptions")} + {sourceFile ? t("noOptions") : t("chooseForOptions")}

)} diff --git a/src/assets/animated-image.svg b/src/assets/animated-image.svg new file mode 100644 index 0000000..1d7fd4a --- /dev/null +++ b/src/assets/animated-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/audio.svg b/src/assets/audio.svg new file mode 100644 index 0000000..46369a7 --- /dev/null +++ b/src/assets/audio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/image.svg b/src/assets/image.svg new file mode 100644 index 0000000..02885ce --- /dev/null +++ b/src/assets/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/video.svg b/src/assets/video.svg new file mode 100644 index 0000000..64cffdd --- /dev/null +++ b/src/assets/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/FormatDropdown.tsx b/src/components/FormatDropdown.tsx new file mode 100644 index 0000000..50c101d --- /dev/null +++ b/src/components/FormatDropdown.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; +import AnimatedImageIcon from "../assets/animated-image.svg"; +import ImageIcon from "../assets/image.svg"; +import VideoIcon from "../assets/video.svg"; +import AudioIcon from "../assets/audio.svg"; +import type { Format, ImageFormat, VideoFormat } from "../formats"; +import { IMAGE_FORMATS, VIDEO_FORMATS } from "../formats"; +import { type Locale, useTranslation } from "../i18n"; + +export function FormatDropdown({ + value, + options, + disabled = false, + onChange, +}: { + value: Format; + options: readonly Format[]; + disabled?: boolean; + onChange: (value: Format) => void; +}) { + const { locale } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [tooltipFormat, setTooltipFormat] = useState(null); + + const formatExplanations: Record> = { + PNG: { + ja: "可逆圧縮により画質劣化がなく透過処理に対応。色数の多い写真等では容量が肥大化しやすい。輪郭の明瞭さが求められるロゴやイラスト、図表の保存に適している。", + en: "Features lossless compression with no quality loss and supports transparency. File sizes can become large for complex images like photos. Best suited for logos, illustrations, and charts requiring crisp edges.", + }, + JPEG: { + ja: "高い圧縮率によりファイルサイズを大幅に削減でき再生互換性に優れる。非可逆圧縮のため保存時に劣化が生じ透過には非対応。写真の保存やWeb用画像に適している。", + en: "Significantly reduces file sizes with high compression and excellent compatibility. Lossy compression causes degradation over saves, and transparency is not supported. Best suited for photographs and web images.", + }, + WebP: { + ja: "JPEGやPNGより高い圧縮効率を持ち透過やアニメーションにも対応。古いブラウザやレガシーソフトウェアでは非対応。Webサイトの表示速度向上を目的とした画像配置に適している。", + en: "Offers higher compression efficiency than JPEG or PNG while supporting transparency and animation. May not be supported in older browsers or legacy software. Best suited for web images to improve loading speeds.", + }, + GIF: { + ja: "標準機能のみで簡易アニメーションを表示でき互換性が高い。最大256色に制限されグラデーションの再現性に劣る。短いループ画像や簡易アニメーションの共有に適している。", + en: "Broadly compatible and displays simple animations natively without plugins. Limited to a maximum of 256 colors, making gradients appear degraded. Best suited for short looping animations and simple graphics.", + }, + MP4: { + ja: "ほぼ全ての端末やブラウザで再生可能な互換性と高い圧縮効率を兼ね備える。コンテナ内のコーデック仕様に依存する。Web配信やSNS投稿、汎用的な動画共有に適している。", + en: "Combines broad compatibility across almost all devices and browsers with high compression efficiency. Playback depends on internal codec support. Best suited for web streaming, social media, and general video sharing.", + }, + WebM: { + ja: "オープン規格でライセンス料が不要でありWeb環境での圧縮率と親和性に優れる。一部の動画編集ソフトや古い端末では非対応。Webサイト上の動画配置やHTML5配信に適している。", + en: "A royalty-free open format optimized for web use with strong compression. Not supported by some older devices or traditional video editors. Best suited for web embedding and HTML5 video streaming.", + }, + AVI: { + ja: "古い規格との互換性が高く非圧縮データも扱える。ファイルサイズが大きくなりやすくストリーミング配信には不向き。古いWindows環境やレガシーソフトでの動画編集に適している。", + en: "Offers high compatibility with legacy systems and supports uncompressed data. File sizes tend to be very large and unsuited for streaming. Best suited for editing and playback in legacy Windows environments.", + }, + MOV: { + ja: "Apple環境との親和性が高く高画質コーデックを扱えるため編集用途に適する。WindowsやAndroid環境での互換性に劣る場合がある。MacやiOS上での動画編集や中間素材の保存に適している。", + en: "Highly integrated with the Apple ecosystem and supports high-quality codecs ideal for editing. Compatibility may be limited on Windows or Android without extra tools. Best suited for video editing and mastering on macOS and iOS.", + }, + MP3: { + ja: "再生機器やOSを選ばない高い互換性を持つ。非可逆圧縮による高音域の欠落があり後発規格より圧縮効率が劣る。端末を問わず再生・共有したい音楽や音声コンテンツに適している。", + en: "Universally compatible across virtually all devices and operating systems. Lossy compression drops high frequencies and has lower efficiency than newer codecs. Best suited for music and audio playback across any device.", + }, + M4A: { + ja: "MP3より圧縮効率に優れ同一ビットレートで高品質を維持できる。古いオーディオ機器では認識されない場合がある。Apple製品での音楽管理やボイスメモの保存に適している。", + en: "Delivers better compression efficiency and audio quality than MP3 at identical bitrates. May not be recognized by older audio players. Best suited for managing music libraries and voice memos on Apple devices.", + }, + AAC: { + ja: "MP3より圧縮効率が高く低容量でも音質を維持しやすい。非可逆圧縮のため音質劣化を完全に防ぐことはできない。動画の音声トラックや配信向けオーディオに適している。", + en: "Provides higher compression efficiency than MP3, maintaining clear quality at lower bitrates. As lossy compression, it cannot retain perfect original fidelity. Best suited for video audio tracks and streaming services.", + }, + WAV: { + ja: "非圧縮のため音質の劣化が一切なく原音を忠実に保持できる。ファイルサイズが非常に大きい。音楽制作やナレーション録音、効果音などのマスター音源保存に適している。", + en: "Uncompressed audio with zero quality loss, preserving the original recording faithfully. Results in very large file sizes. Best suited for music production, studio recording, and master sound effects.", + }, + AIFF: { + ja: "WAVと同様に非圧縮で原音を保持できApple環境との親和性が高い。容量が大きくWindows環境での互換性が劣る場合がある。Macでの音楽制作やスタジオ録音素材の管理に適している。", + en: "Preserves uncompressed original audio equivalent to WAV with native Apple support. Large file sizes and potentially limited compatibility on Windows. Best suited for music production and studio masters on macOS.", + }, + FLAC: { + ja: "可逆圧縮により音質劣化なしでWAVの約半分程度に容量を削減できる。非可逆圧縮よりは容量が大きく一部機器で非対応。ハイレゾ音源やCD音源のアーカイブ保存に適している。", + en: "Lossless compression that reduces file sizes by roughly half compared to WAV without quality loss. Larger than lossy formats and unsupported on some legacy devices. Best suited for archiving high-resolution and CD audio.", + }, + WMA: { + ja: "低ビットレートでも比較的音声を保持できWindows環境との親和性が高い。Apple製品をはじめ非Windows環境での互換性が低い。Windows環境や対応機器での音声管理に適している。", + en: "Maintains relatively good audio quality at low bitrates with native Windows support. Low compatibility on non-Windows platforms like Apple products. Best suited for playback on Windows and supported in-car audio systems.", + }, + OGG: { + ja: "ライセンス料不要のオープン規格でありMP3より高効率。Apple製品などの標準環境では再生非対応。ゲーム開発への組み込み音源やオープンな配信環境に適している。", + en: "A royalty-free open format offering higher compression efficiency than MP3. Lacks native playback support on Apple platforms. Best suited for video game audio assets and open-source streaming.", + }, + OPUS: { + ja: "低遅延かつ低ビットレートでも高い音声品質を維持できる。古い再生機器やソフトウェアでは非対応の場合がある。リアルタイム通話やボイスチャット、通話ログの保存に適している。", + en: "Delivers low latency and exceptional voice quality even at very low bitrates. May not be supported by older media players and legacy software. Best suited for real-time voice chat, VoIP, and call recording.", + }, + }; + + return ( +
{ + if (e.key === "Escape") { + setIsOpen(false); + setTooltipFormat(null); + } + }} + > + + + {isOpen && !disabled && ( + <> +
{ + setIsOpen(false); + setTooltipFormat(null); + }} + /> +
+ {options.map((item) => ( +
+ + +
+
+ {formatExplanations[item][locale]} +
+
+ ))} +
+ + )} +
+ ); +}