From cd595193cf80f19a0ab2f0d6e9316708bc2a7d2a Mon Sep 17 00:00:00 2001 From: treetips Date: Tue, 1 Sep 2026 00:04:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20WebP=E5=9C=A7=E7=B8=AE=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 出力フォーマットに WebP を追加 (OutputFormat.webp, l10n) - 圧縮パラメータを最適化種別ごとに定義 (drawing/photo/default, -m 0/4/6) - PathService / ToolInstaller に libwebp 対応を追加 - Google Storage (storage.googleapis.com) から libwebp 1.6.0 を取得する Skill と setup スクリプトを追加 - tools/libwebp に各OS用 cwebp バイナリを配置 (macos/linux/windows) - About画面に WebP バージョン表示を追加 - 圧縮・変換ドキュメントを更新 --- .gitattributes | 3 + .opencode/skills/setup-webp/SKILL.md | 78 ++++++ .opencode/skills/setup-webp/scripts/setup.sh | 125 ++++++++++ docs/design/feature/about.md | 11 + .../feature/convert-flow/compression.md | 113 +++++++++ docs/design/feature/convert.md | 1 + lib/core/constants/app_constants.dart | 6 +- lib/core/models/output_format.dart | 5 +- lib/data/services/compression_service.dart | 50 +++- lib/data/services/path_service.dart | 14 ++ lib/data/services/tool_installer.dart | 194 ++++++++++++++- lib/l10n/app_en.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/ui/screens/about_screen.dart | 3 + lib/ui/screens/tool_bootstrap.dart | 5 +- tool/setup_webp.dart | 234 ++++++++++++++++++ tools/libwebp/bin/linux/cwebp | 3 + tools/libwebp/bin/macos/cwebp | 3 + tools/libwebp/bin/windows/cwebp.exe | 3 + 19 files changed, 842 insertions(+), 11 deletions(-) create mode 100644 .opencode/skills/setup-webp/SKILL.md create mode 100644 .opencode/skills/setup-webp/scripts/setup.sh create mode 100644 tool/setup_webp.dart create mode 100755 tools/libwebp/bin/linux/cwebp create mode 100755 tools/libwebp/bin/macos/cwebp create mode 100755 tools/libwebp/bin/windows/cwebp.exe diff --git a/.gitattributes b/.gitattributes index 6183ba0..c2d71ea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,4 +11,7 @@ tools/upscal/bin/upscayl-bin filter=lfs diff=lfs merge=lfs -text tools/libjxl/bin/macos/cjxl filter=lfs diff=lfs merge=lfs -text tools/libjxl/bin/macos/*.dylib filter=lfs diff=lfs merge=lfs -text tools/libavif/bin/macos/avifenc filter=lfs diff=lfs merge=lfs -text +tools/libwebp/bin/macos/cwebp filter=lfs diff=lfs merge=lfs -text +tools/libwebp/bin/linux/cwebp filter=lfs diff=lfs merge=lfs -text +tools/libwebp/bin/windows/cwebp.exe filter=lfs diff=lfs merge=lfs -text tools/**/.version filter=lfs diff=lfs merge=lfs -text diff --git a/.opencode/skills/setup-webp/SKILL.md b/.opencode/skills/setup-webp/SKILL.md new file mode 100644 index 0000000..7859a19 --- /dev/null +++ b/.opencode/skills/setup-webp/SKILL.md @@ -0,0 +1,78 @@ +--- +name: setup-webp +description: WebP (libwebp) ツールを Google Storage から取得して tools/libwebp に配置するスキル +--- + +# setup-webp + +`libwebp` は GitHub Releases で配布されていないため、Google Storage (`https://storage.googleapis.com/downloads.webmproject.org/releases/webp/index.html`) から取得する。 + +## バージョン + +- `lib/core/constants/app_constants.dart:webpVersion` で管理(現在 `1.6.0`) + +## 配布アーカイブ + +| OS | Arch | アーカイブ | +|---|---|---| +| macOS | arm64 | `libwebp-1.6.0-mac-arm64.tar.gz` | +| macOS | x86-64 | `libwebp-1.6.0-mac-x86-64.tar.gz` | +| Linux | x86-64 | `libwebp-1.6.0-linux-x86-64.tar.gz` | +| Linux | aarch64 | `libwebp-1.6.0-linux-aarch64.tar.gz` | +| Windows | x64 | `libwebp-1.6.0-windows-x64.zip` | + +ベースURL: `https://storage.googleapis.com/downloads.webmproject.org/releases/webp/` + +## 配置先 + +``` +tools/libwebp/bin/macos/cwebp # mac-arm64 または mac-x86-64 の bin/cwebp +tools/libwebp/bin/macos/cwebp (x86_64版は別途 tools/libwebp/bin/macos-x64 などを用意せず、現行 PathService は macos 単一を想定) +tools/libwebp/bin/linux/cwebp +tools/libwebp/bin/windows/cwebp.exe +``` + +> ローカル開発では実行ホストのアーキテクチャに合わせたアーカイブのみを `tools/libwebp/bin/` に配置すれば動作する。リリース配布時は全OS分を格納する。 + +## Workflow + +1. **バージョン確認** + - `AppConstants.webpVersion` を読む + +2. **ダウンロード** + - `tool/setup_webp.dart` または `scripts/setup-webp.sh` を実行 + ```bash + dart run tool/setup_webp.dart + # または + bash .opencode/skills/setup-webp/scripts/setup.sh + ``` + +3. **展開** + - `tar.gz` は `GZipDecoder` + `TarDecoder`(`package:archive`)で展開 + - `zip` は `ZipDecoder` で展開 + - アーカイブ内の `*/bin/cwebp`(または `cwebp.exe`)を `tools/libwebp/bin//` へコピー + +4. **権限付与** + ```bash + chmod +x tools/libwebp/bin/macos/cwebp + chmod +x tools/libwebp/bin/linux/cwebp + ``` + +5. **検証** + ```bash + tools/libwebp/bin/macos/cwebp -version + tools/libwebp/bin/macos/cwebp -help | head -n 20 + ``` + +6. **アプリ側連携** + - `PathService.libwebpBinDirectory()` / `cwebpBinPath()` が `tools/libwebp/bin/` を解決 + - `ToolInstaller` が `libwebp/bin//cwebp` の存在を `isInstalled()` / `repairMissingTools()` で検証し、欠損時は Google Storage から自動取得(`_downloadAndExtractLibwebp()`) + - `CompressionService._compressWebp()` が `cwebp -preset -q -m -mt input -o output` を実行 + +## 関連ドキュメント + +- `docs/design/feature/convert-flow/compression.md` - WebP 圧縮パラメータ(`WEBP_PRESET` / `WEBP_METHOD`) +- `docs/design/feature/convert.md` - フォーマットプルダウンに `WebP` を追加 +- `lib/data/services/tool_installer.dart` - Google Storage からの取得ロジック +- `lib/data/services/path_service.dart` - `libwebpBinDirectory()` / `cwebpBinPath()` +- `lib/data/services/compression_service.dart` - WebP 圧縮実装 diff --git a/.opencode/skills/setup-webp/scripts/setup.sh b/.opencode/skills/setup-webp/scripts/setup.sh new file mode 100644 index 0000000..20a89ad --- /dev/null +++ b/.opencode/skills/setup-webp/scripts/setup.sh @@ -0,0 +1,125 @@ +#!/bin/bash +set -euo pipefail + +# WebP セットアップスクリプト(shell 版) +# Google Storage から libwebp を取得して tools/libwebp に配置する +# 使用: bash .opencode/skills/setup-webp/scripts/setup.sh [--current-only] + +VERSION="1.6.0" +BASE_URL="https://storage.googleapis.com/downloads.webmproject.org/releases/webp" + +CURRENT_ONLY=false +if [[ "${1:-}" == "--current-only" ]]; then + CURRENT_ONLY=true +fi + +# OS/Arch 判定 +OS="$(uname -s)" +ARCH="$(uname -m)" +echo "VERSION=$VERSION OS=$OS ARCH=$ARCH CURRENT_ONLY=$CURRENT_ONLY" + +download_and_extract() { + local archive_name="$1" + local dest_dir="$2" + local url="$BASE_URL/$archive_name" + local tmp_file="/tmp/$archive_name" + + echo "=== $archive_name -> $dest_dir ===" + echo "url=$url" + + if [[ -f "$tmp_file" ]]; then + rm -f "$tmp_file" + fi + + echo "downloading..." + curl -L -o "$tmp_file" "$url" + echo "downloaded $tmp_file ($(du -h "$tmp_file" | cut -f1))" + + mkdir -p "$dest_dir" + local exe_name="cwebp" + if [[ "$dest_dir" == *"windows"* ]]; then + exe_name="cwebp.exe" + fi + + if [[ "$archive_name" == *.zip ]]; then + unzip -o -j "$tmp_file" "*/bin/$exe_name" -d "$dest_dir" 2>/dev/null || { + echo "Trying alternative unzip pattern..." + unzip -l "$tmp_file" | grep cwebp + unzip -o "$tmp_file" -d "/tmp/webp_extract" + find /tmp/webp_extract -name "$exe_name" -type f -exec cp {} "$dest_dir/$exe_name" \; + rm -rf /tmp/webp_extract + } + # unzip -j は bin パスを除去して直接配置する + if [[ -f "$dest_dir/$exe_name" ]]; then + echo "extracted $dest_dir/$exe_name" + else + # fallback: find + find /tmp -name "$exe_name" 2>/dev/null | head -5 + echo "failed to extract $exe_name from $archive_name" + unzip -l "$tmp_file" | head -20 + exit 1 + fi + else + local tmp_extract="/tmp/webp_extract_$$" + mkdir -p "$tmp_extract" + tar -xzf "$tmp_file" -C "$tmp_extract" + local found + found="$(find "$tmp_extract" -name "$exe_name" -type f | head -n1)" + if [[ -z "$found" ]]; then + echo "cwebp not found in $archive_name" + find "$tmp_extract" -type f | head -20 + rm -rf "$tmp_extract" + exit 1 + fi + cp "$found" "$dest_dir/$exe_name" + echo "extracted $dest_dir/$exe_name from $found" + rm -rf "$tmp_extract" + fi + + if [[ "$dest_dir" != *"windows"* ]]; then + chmod +x "$dest_dir/$exe_name" + echo "chmod +x $dest_dir/$exe_name" + fi + + if [[ -f "$dest_dir/$exe_name" ]]; then + echo "verify: $dest_dir/$exe_name -version" + "$dest_dir/$exe_name" -version || "$dest_dir/$exe_name" -help | head -n 5 || true + fi + + rm -f "$tmp_file" +} + +if [[ "$CURRENT_ONLY" == true ]]; then + if [[ "$OS" == "Darwin" ]]; then + if [[ "$ARCH" == "arm64" ]]; then + download_and_extract "libwebp-${VERSION}-mac-arm64.tar.gz" "tools/libwebp/bin/macos" + else + download_and_extract "libwebp-${VERSION}-mac-x86-64.tar.gz" "tools/libwebp/bin/macos" + fi + elif [[ "$OS" == "Linux" ]]; then + if [[ "$ARCH" == "aarch64" ]]; then + download_and_extract "libwebp-${VERSION}-linux-aarch64.tar.gz" "tools/libwebp/bin/linux" + else + download_and_extract "libwebp-${VERSION}-linux-x86-64.tar.gz" "tools/libwebp/bin/linux" + fi + elif [[ "$OS" == *"MINGW"* || "$OS" == *"MSYS"* || "$OS" == *"CYGWIN"* ]]; then + download_and_extract "libwebp-${VERSION}-windows-x64.zip" "tools/libwebp/bin/windows" + else + echo "Unknown OS: $OS, trying macos arm64 as fallback" + download_and_extract "libwebp-${VERSION}-mac-arm64.tar.gz" "tools/libwebp/bin/macos" + fi +else + # 全OS分をダウンロード(配布用) + download_and_extract "libwebp-${VERSION}-mac-arm64.tar.gz" "tools/libwebp/bin/macos" + # x86_64 は同じ dest なので別途保存しない(必要なら macos-x64 として分離可能) + # download_and_extract "libwebp-${VERSION}-mac-x86-64.tar.gz" "tools/libwebp/bin/macos-x64" + download_and_extract "libwebp-${VERSION}-linux-x86-64.tar.gz" "tools/libwebp/bin/linux" + # aarch64 は同様にスキップ + download_and_extract "libwebp-${VERSION}-windows-x64.zip" "tools/libwebp/bin/windows" +fi + +echo "" +echo "Done. Installed:" +ls -lh tools/libwebp/bin/macos/cwebp 2>/dev/null || echo " macos: not found" +ls -lh tools/libwebp/bin/linux/cwebp 2>/dev/null || echo " linux: not found" +ls -lh tools/libwebp/bin/windows/cwebp.exe 2>/dev/null || echo " windows: not found" diff --git a/docs/design/feature/about.md b/docs/design/feature/about.md index ccfca13..53daa11 100644 --- a/docs/design/feature/about.md +++ b/docs/design/feature/about.md @@ -146,3 +146,14 @@ - 現在のバージョンを表示します。 - バージョン番号はGitHub Releasesの `タグ名` です。 - バージョン番号はFlutterの定数クラスで管理するので、そこから取得します。 + +### ✏️ WebP + +#### 静的ラベル + +- `WebP (libwebp)` という静的ラベルを表示します。 + +#### 動的ラベル + +- 現在のバージョンを表示します。 +- バージョン番号はFlutterの定数クラスで管理するので、そこから取得します。 diff --git a/docs/design/feature/convert-flow/compression.md b/docs/design/feature/convert-flow/compression.md index 886cb55..dd29c58 100644 --- a/docs/design/feature/convert-flow/compression.md +++ b/docs/design/feature/convert-flow/compression.md @@ -370,3 +370,116 @@ aom-specific advanced options: Version: 1.4.2 (dav1d [dec]:1.5.4, aom [enc/dec]:3.14.1) libyuv : unavailable ``` + +## ✳️ WebP + +### ✏️ コマンド + +```shell +cwebp \ + -preset "${WEBP_PRESET}" \ + -q "${QUALITY}" \ + -m "${WEBP_METHOD}" \ + -mt \ + "$input" \ + -o "$output" +``` + +拡張子は固定で `.webp` とします。 +`最適化種別` 毎のパラメータは以下で固定します。 + +| 最適化種別 | WEBP_PRESET | WEBP_METHOD (`-m` 0=fast..6=slowest) | 補足 | +|----------|-------------|--------------------------------------|------------------------------------------------------------------| +| アニメ・イラスト | drawing | 4 | エッジ・ベタ塗り保持に強い `drawing`。デフォルトの `-m 4` で品質と速度のバランス | +| 実写・写真 | photo | 4 | 実写向けチューニングの `photo`。`-m 4`(デフォルト)でバランス重視 | +| 速度優先 | default | 0 | プリセット補正なしの `default` + `-m 0`(最速)でエンコード時間最優先。`-sns`/`-f` 等はデフォルト | +| 画質最優先 | photo | 6 | 実写/イラスト両用で高圧縮な `photo` + `-m 6`(最遅・最高圧縮効率)。速度を犠牲にサイズと歪みを最優先 | + +> 参考: `-m` は圧縮効率(探索量)であり、`0` が最速・低圧縮、`6` が最遅・高圧縮。`-preset` は `drawing` / `photo` / `default` などが内部の `-sns` / `-f` / `-sharpness` / セグメント等のデフォルトを上書きするため、`-preset` を先頭に置いた上で `-m` を上書きする運用とします。 + +### ✏️ help + +```shell +❯ cwebp --help + +Usage: + cwebp [-preset <...>] [options] in_file [-o out_file] + +If input size (-s) for an image is not specified, it is +assumed to be a PNG, JPEG, TIFF or WebP file. +Note: Animated PNG and WebP files are not supported. + +Options: + -h / -help ............. short help + -H / -longhelp ......... long help + -q ............. quality factor (0:small..100:big), default=75 + -alpha_q ......... transparency-compression quality (0..100), + default=100 + -preset ....... preset setting, one of: + default, photo, picture, + drawing, icon, text + -preset must come first, as it overwrites other parameters + -z ............... activates lossless preset with given + level in [0:fast, ..., 9:slowest] + + -m ............... compression method (0=fast, 6=slowest), default=4 + -segments ........ number of segments to use (1..4), default=4 + -size ............ target size (in bytes) + -psnr .......... target PSNR (in dB. typically: 42) + + -s ......... input size (width x height) for YUV + -sns ............. spatial noise shaping (0:off, 100:max), default=50 + -f ............... filter strength (0=off..100), default=60 + -sharpness ....... filter sharpness (0:most .. 7:least sharp), default=0 + -strong ................ use strong filter instead of simple (default) + -nostrong .............. use simple filter instead of strong + -sharp_yuv ............. use sharper (and slower) RGB->YUV conversion + -partition_limit . limit quality to fit the 512k limit on + the first partition (0=no degradation ... 100=full) + -pass ............ analysis pass number (1..10) + -qrange .... specifies the permissible quality range + (default: 0 100) + -crop .. crop picture with the given rectangle + -resize ........ resize picture (*after* any cropping) + -resize_mode .. one of: up_only, down_only, always (default) + -mt .................... use multi-threading if available + -low_memory ............ reduce memory usage (slower encoding) + -map ............. print map of extra info + -print_psnr ............ prints averaged PSNR distortion + -print_ssim ............ prints averaged SSIM distortion + -print_lsim ............ prints local-similarity distortion + -d .......... dump the compressed output (PGM file) + -alpha_method .... transparency-compression method (0..1), default=1 + -alpha_filter . predictive filtering for alpha plane, + one of: none, fast (default) or best + -exact ................. preserve RGB values in transparent area, default=off + -blend_alpha ..... blend colors against background color + expressed as RGB values written in + hexadecimal, e.g. 0xc0e0d0 for red=0xc0 + green=0xe0 and blue=0xd0 + -noalpha ............... discard any transparency information + -lossless .............. encode image losslessly, default=off + -near_lossless ... use near-lossless image preprocessing + (0..100=off), default=100 + -hint ......... specify image characteristics hint, + one of: photo, picture or graph + + -metadata ..... comma separated list of metadata to + copy from the input to the output if present. + Valid values: all, none (default), exif, icc, xmp + + -short ................. condense printed message + -quiet ................. don't print anything + -version ............... print version number and exit + -noasm ................. disable all assembly optimizations + -v ..................... verbose, e.g. print encoding/decoding times + -progress .............. report encoding progress + +Experimental Options: + -jpeg_like ............. roughly match expected JPEG size + -af .................... auto-adjust filter strength + -pre ............. pre-processing filter + +Supported input formats: + WebP, JPEG, PNG, PNM (PGM, PPM, PAM), TIFF +``` \ No newline at end of file diff --git a/docs/design/feature/convert.md b/docs/design/feature/convert.md index 5a8d312..2751a8d 100644 --- a/docs/design/feature/convert.md +++ b/docs/design/feature/convert.md @@ -113,6 +113,7 @@ - PNG - JPEG XL - AV1 + - WebP - 初期値は `JPEG XL` です。 #### ℹ️ diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 09b6807..879b2e9 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -17,6 +17,9 @@ class AppConstants { /// AV1 (libavif) のバージョン。 static const String av1Version = 'v1.4.2'; + /// WebP (libwebp) のバージョン。 + static const String webpVersion = '1.6.0'; + /// GitHub のリポジトリオーナー。 static const String githubOwner = 'treetips'; @@ -34,7 +37,8 @@ class AppConstants { /// 値は GitHub Releases の `tools-v{version}.zip` の実ハッシュと一致させること。 /// リリースワークフロー(release.yml)で build 前に自動差し替えられるため、 /// 通常は手動で変更する必要はない。 - static const String toolsSha256 = '9d00d1268aefcdf2bedd0a63da3cc8c9e0bf81059d3ed072b5512810aab63d6b'; + static const String toolsSha256 = + '9d00d1268aefcdf2bedd0a63da3cc8c9e0bf81059d3ed072b5512810aab63d6b'; /// アップデート情報 JSON の URL。 /// diff --git a/lib/core/models/output_format.dart b/lib/core/models/output_format.dart index 86ab37b..745ec8d 100644 --- a/lib/core/models/output_format.dart +++ b/lib/core/models/output_format.dart @@ -5,7 +5,8 @@ enum OutputFormat { jpeg, png, jpegXl, - av1; + av1, + webp; const OutputFormat(); @@ -15,6 +16,7 @@ enum OutputFormat { OutputFormat.png => l10n.formatPng, OutputFormat.jpegXl => l10n.formatJpegXl, OutputFormat.av1 => l10n.formatAv1, + OutputFormat.webp => l10n.formatWebp, }; /// 出力ファイルの拡張子。 @@ -23,5 +25,6 @@ enum OutputFormat { OutputFormat.png => '.png', OutputFormat.jpegXl => '.jxl', OutputFormat.av1 => '.avif', + OutputFormat.webp => '.webp', }; } diff --git a/lib/data/services/compression_service.dart b/lib/data/services/compression_service.dart index c8a941c..f139089 100644 --- a/lib/data/services/compression_service.dart +++ b/lib/data/services/compression_service.dart @@ -42,8 +42,19 @@ class _AvifParams { final List aomOpts; } +/// WebP の最適化種別ごとのパラメータ。 +class _WebpParams { + const _WebpParams(this.preset, this.method); + + /// プリセット。`default` | `photo` | `picture` | `drawing` | `icon` | `text` + final String preset; + + /// 圧縮メソッド (0=fast .. 6=slowest)。 + final int method; +} + /// 画像圧縮処理を実行するサービス。 -/// `jpegoptim` / `pngoptim` / `cjxl` / `avifenc` コマンドを利用する。 +/// `jpegoptim` / `pngoptim` / `cjxl` / `avifenc` / `cwebp` コマンドを利用する。 class CompressionService { CompressionService({ required this.pathService, @@ -98,6 +109,13 @@ class CompressionService { ]), }; + static const Map _webpParams = { + OptimizeType.anime: _WebpParams('drawing', 4), + OptimizeType.photo: _WebpParams('photo', 4), + OptimizeType.speed: _WebpParams('default', 0), + OptimizeType.quality: _WebpParams('photo', 6), + }; + /// [input] を [format]・[optimizeType]・[quality] に従って圧縮し、[output] に出力する。 Future> compress({ required String input, @@ -117,6 +135,8 @@ class CompressionService { await _compressJpegXl(input, output, optimizeType, quality, threads); case OutputFormat.av1: await _compressAv1(input, output, optimizeType, quality); + case OutputFormat.webp: + await _compressWebp(input, output, optimizeType, quality); } return Result.ok(null); } on Object catch (error, stackTrace) { @@ -309,6 +329,34 @@ class CompressionService { ], workingDirectory: binDir.path); } + Future _compressWebp( + String input, + String output, + OptimizeType optimizeType, + int quality, + ) async { + final binPath = await pathService.cwebpBinPath(); + final binDir = await pathService.libwebpBinDirectory(); + final params = _webpParams[optimizeType]!; + _log?.logger.info( + '圧縮(WebP): input=$input output=$output type=${optimizeType.name} ' + 'quality=$quality preset=${params.preset} method=${params.method} bin=$binPath', + ); + + await processService.run(binPath, [ + '-preset', + params.preset, + '-q', + '$quality', + '-m', + '${params.method}', + '-mt', + input, + '-o', + output, + ], workingDirectory: binDir.path); + } + /// 品質 (1-100) を JPEG XL の距離 (0-25) に変換する。 /// 100 はロスレス相当 (0)、80 は視覚的にほぼロスレス相当 (約1.0) となる。 double _qualityToDistance(int quality) { diff --git a/lib/data/services/path_service.dart b/lib/data/services/path_service.dart index eb407e0..46d393e 100644 --- a/lib/data/services/path_service.dart +++ b/lib/data/services/path_service.dart @@ -129,6 +129,20 @@ class PathService { return '${bin.path}/$executableName'; } + /// libwebp の bin ディレクトリ。 + Future libwebpBinDirectory() async { + final tools = await toolsDirectory(); + final os = _osName(); + return Directory('${tools.path}/libwebp/bin/$os'); + } + + /// cwebp の実行ファイルパス。 + Future cwebpBinPath() async { + final bin = await libwebpBinDirectory(); + final executableName = Platform.isWindows ? 'cwebp.exe' : 'cwebp'; + return '${bin.path}/$executableName'; + } + String _osName() { if (Platform.isWindows) return 'windows'; if (Platform.isLinux) return 'linux'; diff --git a/lib/data/services/tool_installer.dart b/lib/data/services/tool_installer.dart index d64cc81..1bf9d89 100644 --- a/lib/data/services/tool_installer.dart +++ b/lib/data/services/tool_installer.dart @@ -30,6 +30,7 @@ class ToolInstaller { 'avifenc', 'jpegoptim', 'pngoptim', + 'cwebp', ]; /// Application Support 内の tools ディレクトリ。 @@ -115,6 +116,9 @@ class ToolInstaller { File( '${dir.path}/libavif/bin/$os/${Platform.isWindows ? 'avifenc.exe' : 'avifenc'}', ), + File( + '${dir.path}/libwebp/bin/$os/${Platform.isWindows ? 'cwebp.exe' : 'cwebp'}', + ), ]; for (final f in required) { final exists = f.existsSync(); @@ -188,6 +192,11 @@ class ToolInstaller { binary: 'libavif/bin/$os/${Platform.isWindows ? 'avifenc.exe' : 'avifenc'}', ), + _ToolCheck( + name: 'libwebp', + folder: 'libwebp', + binary: 'libwebp/bin/$os/${Platform.isWindows ? 'cwebp.exe' : 'cwebp'}', + ), ]; // 欠損しているフォルダを収集し、単一 ZIP のダウンロードは1回だけ行う @@ -225,14 +234,17 @@ class ToolInstaller { } // 欠損がある場合は単一 ZIP をダウンロードして展開(ローカルに tools があればそちらを優先) - if (missingFolders.isNotEmpty) { + // libwebp は Google Storage から取得するため分離して処理する + final libwebpMissing = missingFolders.contains('libwebp'); + final nonWebpMissing = missingFolders.where((f) => f != 'libwebp').toList(); + if (nonWebpMissing.isNotEmpty) { _log( - 'missing folders: $missingFolders, attempting to restore', + 'missing folders: $nonWebpMissing, attempting to restore', name: 'ToolInstaller', ); // ローカルに tools があればそこからコピー(開発環境) bool restoredFromLocal = false; - for (final folder in missingFolders) { + for (final folder in nonWebpMissing) { for (final candidate in _sourceCandidates()) { final src = Directory('${candidate.path}/$folder'); if (await src.exists()) { @@ -255,13 +267,41 @@ class ToolInstaller { await _downloadAndExtractToolsZip(onProgress); } else { // ローカルから1つでも復元できた場合は残りもローカルから - for (final folder in missingFolders) { + for (final folder in nonWebpMissing) { final dest = Directory('${toolsDir.path}/$folder'); if (await dest.exists()) continue; await _copyToolFolder(folder, onProgress); } } } + if (libwebpMissing) { + _log('missing libwebp, attempting to restore', name: 'ToolInstaller'); + bool restored = false; + for (final candidate in _sourceCandidates()) { + final src = Directory('${candidate.path}/libwebp'); + if (await src.exists()) { + _log('copy libwebp from candidate $candidate', name: 'ToolInstaller'); + final dest = Directory('${toolsDir.path}/libwebp'); + await dest.create(recursive: true); + await _copyDirectory(src, dest, onProgress); + restored = true; + break; + } + } + if (!restored) { + // ローカルに無ければ Google Storage からダウンロード + await _downloadAndExtractLibwebp(onProgress); + } else { + // ローカルから復元できたが、バイナリが存在しない場合は追加で試す + final os = _osName(); + final binFile = File( + '${toolsDir.path}/libwebp/bin/$os/${Platform.isWindows ? 'cwebp.exe' : 'cwebp'}', + ); + if (!binFile.existsSync()) { + await _downloadAndExtractLibwebp(onProgress); + } + } + } // upscal/models は1ファイルも存在しなければフォルダごと削除して再取得 final modelsDir = Directory('${toolsDir.path}/upscal/models'); @@ -426,6 +466,146 @@ class ToolInstaller { } } + /// libwebp のアーカイブ URL を現在 OS/Arch から決定する。 + Future _libwebpArchiveUrl() async { + final version = AppConstants.webpVersion; + if (Platform.isWindows) { + return 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$version-windows-x64.zip'; + } + if (Platform.isLinux) { + var arch = 'x86-64'; + try { + final result = await Process.run('uname', ['-m']); + final out = (result.stdout as String).trim().toLowerCase(); + if (out.contains('aarch64') || out.contains('arm64')) { + arch = 'aarch64'; + } + } catch (_) {} + if (arch == 'aarch64') { + return 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$version-linux-aarch64.tar.gz'; + } + return 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$version-linux-x86-64.tar.gz'; + } + // macOS + var arch = 'arm64'; + try { + final result = await Process.run('uname', ['-m']); + final out = (result.stdout as String).trim().toLowerCase(); + if (out.contains('x86_64')) { + arch = 'x86-64'; + } else if (out.contains('arm64') || out.contains('aarch64')) { + arch = 'arm64'; + } + } catch (_) {} + if (arch == 'x86-64') { + return 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$version-mac-x86-64.tar.gz'; + } + return 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$version-mac-arm64.tar.gz'; + } + + /// Google Storage から libwebp をダウンロードして `tools/libwebp` に展開する。 + Future _downloadAndExtractLibwebp(InstallProgress? onProgress) async { + final url = await _libwebpArchiveUrl(); + _log('downloading libwebp from $url', name: 'ToolInstaller'); + final toolsDir = await toolsDirectory(); + final parent = toolsDir.parent; + await parent.create(recursive: true); + final isZip = url.endsWith('.zip'); + final tmpName = + 'libwebp-${AppConstants.webpVersion}.${isZip ? 'zip.tmp' : 'tar.gz.tmp'}'; + final destName = + 'libwebp-${AppConstants.webpVersion}.${isZip ? 'zip' : 'tar.gz'}'; + final tmpFile = File('${parent.path}/$tmpName'); + final destFile = File('${parent.path}/$destName'); + + try { + final client = http.Client(); + final request = http.Request('GET', Uri.parse(url)); + final response = await client.send(request); + _log( + 'libwebp download response: status=${response.statusCode}', + name: 'ToolInstaller', + ); + if (response.statusCode != 200) { + throw Exception( + 'Failed to download libwebp: ${response.statusCode} $url', + ); + } + final total = + response.contentLength ?? + int.tryParse(response.headers['content-length'] ?? '') ?? + 0; + var received = 0; + final sink = tmpFile.openWrite(); + await for (final chunk in response.stream) { + received += chunk.length; + sink.add(chunk); + onProgress?.call(received, total); + } + await sink.close(); + client.close(); + await tmpFile.rename(destFile.path); + _log('downloaded libwebp to ${destFile.path}', name: 'ToolInstaller'); + + final os = _osName(); + final binDir = Directory('${toolsDir.path}/libwebp/bin/$os'); + await binDir.create(recursive: true); + + final executableName = Platform.isWindows ? 'cwebp.exe' : 'cwebp'; + final targetPath = '${binDir.path}/$executableName'; + + if (isZip) { + final bytes = await destFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + File? cwebpFile; + for (final file in archive) { + if (file.isFile && file.name.endsWith('/bin/$executableName')) { + cwebpFile = File(targetPath); + await cwebpFile.parent.create(recursive: true); + await cwebpFile.writeAsBytes(file.content as List); + break; + } + } + if (cwebpFile == null || !await cwebpFile.exists()) { + throw Exception('cwebp not found in libwebp zip: $url'); + } + } else { + final bytes = await destFile.readAsBytes(); + final gzBytes = GZipDecoder().decodeBytes(bytes); + final archive = TarDecoder().decodeBytes(gzBytes); + File? cwebpFile; + for (final file in archive) { + // archive file names are like libwebp-1.6.0-mac-arm64/bin/cwebp + if (file.isFile && file.name.endsWith('/bin/$executableName')) { + cwebpFile = File(targetPath); + await cwebpFile.parent.create(recursive: true); + await cwebpFile.writeAsBytes(file.content as List); + break; + } + } + if (cwebpFile == null || !await cwebpFile.exists()) { + throw Exception('cwebp not found in libwebp tar.gz: $url'); + } + } + + if (Platform.isMacOS || Platform.isLinux) { + await Process.run('chmod', ['+x', targetPath]); + } + _log('extracted libwebp cwebp to $targetPath', name: 'ToolInstaller'); + try { + await destFile.delete(); + } catch (_) {} + } on Object catch (e, st) { + _log( + 'failed to download libwebp: $e', + name: 'ToolInstaller', + error: e, + stackTrace: st, + ); + rethrow; + } + } + Future _currentVersion() async { try { final info = await PackageInfo.fromPlatform(); @@ -512,6 +692,12 @@ class ToolInstaller { } // 3. GitHub Releases の単一 ZIP からダウンロード(Release 環境) + // libwebp は Google Storage から取得 + if (relativeFolder == 'libwebp' || relativeFolder.startsWith('libwebp/')) { + _log('try download libwebp for $relativeFolder', name: 'ToolInstaller'); + await _downloadAndExtractLibwebp(onProgress); + return; + } _log( 'try download single tools zip for $relativeFolder', name: 'ToolInstaller', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 26b47fc..d775e02 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -138,6 +138,7 @@ "formatPng": "PNG", "formatJpegXl": "JPEG XL", "formatAv1": "AV1", + "formatWebp": "WebP", "notifySuccess": "🟢 Conversion completed successfully.", "notifyFailure": "🔴 Conversion failed.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index acc360b..4e256ea 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -115,6 +115,7 @@ "formatPng": "PNG", "formatJpegXl": "JPEG XL", "formatAv1": "AV1", + "formatWebp": "WebP", "notifySuccess": "🟢 変換処理が成功しました。", "notifyFailure": "🔴 変換処理が失敗しました。", diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart index efb8cab..36333c6 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -308,6 +308,9 @@ class _VersionTable extends StatelessWidget { TableRow( children: [_cell('AV1 (libavif)'), _cell(AppConstants.av1Version)], ), + TableRow( + children: [_cell('WebP (libwebp)'), _cell(AppConstants.webpVersion)], + ), ], ); } diff --git a/lib/ui/screens/tool_bootstrap.dart b/lib/ui/screens/tool_bootstrap.dart index 69f84fa..2c7e084 100644 --- a/lib/ui/screens/tool_bootstrap.dart +++ b/lib/ui/screens/tool_bootstrap.dart @@ -86,10 +86,7 @@ class _ToolBootstrapState extends ConsumerState { fontSize: 12, fontWeight: FontWeight.bold, shadows: [ - Shadow( - color: Colors.black45, - blurRadius: 2, - ), + Shadow(color: Colors.black45, blurRadius: 2), ], ), ), diff --git a/tool/setup_webp.dart b/tool/setup_webp.dart new file mode 100644 index 0000000..a55d4ac --- /dev/null +++ b/tool/setup_webp.dart @@ -0,0 +1,234 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:http/http.dart' as http; + +/// Google Storage から libwebp を取得して tools/libwebp に配置するスクリプト. +/// +/// 実行: +/// dart run tool/setup_webp.dart +/// +/// AppConstants.webpVersion と同期させること。 +const String webpVersion = '1.6.0'; + +const String baseUrl = + 'https://storage.googleapis.com/downloads.webmproject.org/releases/webp'; + +const Map archives = { + 'macos-arm64': 'libwebp-$webpVersion-mac-arm64.tar.gz', + 'macos-x86-64': 'libwebp-$webpVersion-mac-x86-64.tar.gz', + 'linux-x86-64': 'libwebp-$webpVersion-linux-x86-64.tar.gz', + 'linux-aarch64': 'libwebp-$webpVersion-linux-aarch64.tar.gz', + 'windows-x64': 'libwebp-$webpVersion-windows-x64.zip', +}; + +/// 配置先マッピング: archiveKey -> `tools/libwebp/bin//` +const Map destMap = { + 'macos-arm64': 'tools/libwebp/bin/macos', + 'macos-x86-64': 'tools/libwebp/bin/macos', // 上書き注意: 本番では両archを個別に保持したい場合は分離する + 'linux-x86-64': 'tools/libwebp/bin/linux', + 'linux-aarch64': 'tools/libwebp/bin/linux', + 'windows-x64': 'tools/libwebp/bin/windows', +}; + +Future main(List args) async { + final onlyCurrent = args.contains('--current-only'); + final currentOs = _currentOsName(); + final currentArch = await _currentArch(); + print('webpVersion=$webpVersion os=$currentOs arch=$currentArch'); + + final targets = {}; + if (onlyCurrent) { + final key = _keyForCurrent(currentOs, currentArch); + if (key != null) { + targets[key] = archives[key]!; + print('current-only mode: $key -> ${archives[key]}'); + } else { + print('Unsupported current platform: $currentOs $currentArch'); + exit(1); + } + } else { + targets.addAll(archives); + print('Downloading all archives: ${targets.keys.join(", ")}'); + } + + final tmpDir = Directory.systemTemp.createTempSync('setup_webp'); + print('tmpDir=${tmpDir.path}'); + + for (final entry in targets.entries) { + final key = entry.key; + final archiveName = entry.value; + final url = '$baseUrl/$archiveName'; + final destDir = destMap[key]!; + print('\n=== $key ==='); + print('url=$url'); + print('dest=$destDir'); + + final archivePath = '${tmpDir.path}/$archiveName'; + await _download(url, archivePath); + + final isZip = archiveName.endsWith('.zip'); + final executableName = key.startsWith('windows') ? 'cwebp.exe' : 'cwebp'; + final targetPath = '$destDir/$executableName'; + + await Directory(destDir).create(recursive: true); + + if (isZip) { + final bytes = await File(archivePath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + bool found = false; + for (final file in archive) { + if (file.isFile && file.name.endsWith('/bin/$executableName')) { + final target = File(targetPath); + // macos-arm64 と macos-x86-64 が同じ dest に上書きされるのを避けるため、 + // --current-only 以外で macos-x86-64 はスキップするか、別名で保存する + // ここでは current-only でない場合、最初にダウンロードした macos-arm64 を優先し、x86-64 はスキップする + if (key == 'macos-x86-64' && !onlyCurrent) { + print( + 'skip $key (already populated macos from arm64) -> $targetPath', + ); + found = true; + break; + } + await target.writeAsBytes(file.content as List); + print('extracted $targetPath (${file.size} bytes)'); + found = true; + break; + } + } + if (!found) { + print('cwebp not found in $archiveName'); + // list files for debug + for (final f in archive) { + if (f.name.contains('cwebp')) print(' candidate: ${f.name}'); + } + exit(1); + } + } else { + final bytes = await File(archivePath).readAsBytes(); + final gzBytes = GZipDecoder().decodeBytes(bytes); + final archive = TarDecoder().decodeBytes(gzBytes); + bool found = false; + for (final file in archive) { + if (file.isFile && file.name.endsWith('/bin/$executableName')) { + if (key == 'macos-x86-64' && !onlyCurrent) { + print('skip $key (already populated macos from arm64)'); + found = true; + break; + } + // linux-aarch64 も linux-x86-64 と同じ dest なので同様にスキップ + if (key == 'linux-aarch64' && !onlyCurrent) { + print('skip $key (already populated linux from x86-64)'); + found = true; + break; + } + final target = File(targetPath); + await target.writeAsBytes(file.content as List); + print('extracted $targetPath (${file.size} bytes)'); + found = true; + break; + } + } + if (!found) { + print('cwebp not found in $archiveName'); + exit(1); + } + } + + if (!key.startsWith('windows')) { + final result = await Process.run('chmod', ['+x', targetPath]); + if (result.exitCode != 0) { + print('chmod failed: ${result.stderr}'); + } else { + print('chmod +x $targetPath'); + } + } + + // Verify only if current OS matches target + final shouldVerify = + (key.startsWith('macos') && currentOs == 'macos') || + (key.startsWith('linux') && currentOs == 'linux') || + (key.startsWith('windows') && currentOs == 'windows'); + if (shouldVerify) { + final verify = await Process.run(targetPath, ['-version']); + print( + 'verify $targetPath -version: exit=${verify.exitCode} stdout=${verify.stdout} stderr=${verify.stderr}', + ); + } else { + print('skip verify for $key (currentOs=$currentOs)'); + } + } + + print('\nDone. Cleaning tmpDir'); + try { + tmpDir.deleteSync(recursive: true); + } catch (_) {} + + print('\nInstalled:'); + for (final dest in destMap.values.toSet()) { + final exe = dest.contains('windows') ? 'cwebp.exe' : 'cwebp'; + final f = File('$dest/$exe'); + print( + ' $dest/$exe exists=${f.existsSync()} size=${f.existsSync() ? f.lengthSync() : 0}', + ); + } +} + +String _currentOsName() { + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'macos'; +} + +Future _currentArch() async { + try { + final result = await Process.run('uname', ['-m']); + return (result.stdout as String).trim().toLowerCase(); + } catch (_) { + return 'unknown'; + } +} + +String? _keyForCurrent(String os, String arch) { + final a = arch.toLowerCase(); + if (os == 'macos') { + if (a.contains('arm64') || a.contains('aarch64')) return 'macos-arm64'; + if (a.contains('x86_64')) return 'macos-x86-64'; + return 'macos-arm64'; // default to arm64 on Apple Silicon + } + if (os == 'linux') { + if (a.contains('aarch64') || a.contains('arm64')) return 'linux-aarch64'; + return 'linux-x86-64'; + } + if (os == 'windows') return 'windows-x64'; + return null; +} + +Future _download(String url, String destPath) async { + print('downloading $url -> $destPath'); + final client = http.Client(); + final request = http.Request('GET', Uri.parse(url)); + final response = await client.send(request); + if (response.statusCode != 200) { + throw Exception('Failed to download $url: ${response.statusCode}'); + } + final file = File(destPath); + await file.parent.create(recursive: true); + final sink = file.openWrite(); + var received = 0; + final total = response.contentLength ?? 0; + await for (final chunk in response.stream) { + received += chunk.length; + sink.add(chunk); + if (total > 0) { + final pct = (received / total * 100).toStringAsFixed(1); + stdout.write('\r $received/$total bytes ($pct%)'); + } else { + stdout.write('\r $received bytes'); + } + } + await sink.close(); + client.close(); + print('\n downloaded $received bytes'); +} diff --git a/tools/libwebp/bin/linux/cwebp b/tools/libwebp/bin/linux/cwebp new file mode 100755 index 0000000..a8d9975 --- /dev/null +++ b/tools/libwebp/bin/linux/cwebp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ac7ece6bdda1587f3c169ce74ab2734994f9d14e07fc5358208443c1b29edec +size 4948928 diff --git a/tools/libwebp/bin/macos/cwebp b/tools/libwebp/bin/macos/cwebp new file mode 100755 index 0000000..f46db5d --- /dev/null +++ b/tools/libwebp/bin/macos/cwebp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07ba910950253f18ca227d085848ff39be530f6ef22005e778cd48d4850734f2 +size 2612136 diff --git a/tools/libwebp/bin/windows/cwebp.exe b/tools/libwebp/bin/windows/cwebp.exe new file mode 100755 index 0000000..98cc7a4 --- /dev/null +++ b/tools/libwebp/bin/windows/cwebp.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a2f5cb5dce71366353ab1d9caf9c636e039f25703acfce1c148eed346f2f72a +size 753664