1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HEIC → JPG 変換CLIをGoで自作した話

1
Posted at

はじめに

日常的にブログを書く中で、ずっと感じていた小さなストレスがあります。
iPhoneで撮った写真をMacに送ると、HEIC形式のまま保存されることです。
noteに貼るためにJPEGへ変換する必要があり、そのたびにコマンドを打つのが少し面倒でした。

これ、コマンドラインツールで自動化できるのでは?

そう思い、実際に自分用のCLIを作ってみることにしました。


できること

# 指定したファイルを変換
heic2jpg a.heic b.heic

# 今いるフォルダのHEICをまとめて変換
heic2jpg --all

# ダウンロードフォルダを監視して自動変換(引数省略で ~/Downloads)
heic2jpg --watch
heic2jpg --watch ./任意のフォルダ

試してみたい方へ

GitHubで公開しているので、よければ使ってみてください。
※ Macのみ対応(Mac標準の sips コマンドに依存しているため)

Homebrewが使える場合はこちら:

brew install hirune05/tap/heic2jpg

Goが入っている場合はこちらでも可(※~/go/binにPATHを通す必要があります):

go install github.com/hirune05/heic2jpg@latest

なぜGoを選んだのか

最初は「Pythonでサクッと作ればいいか」とも思いました。ただ、今回は今後の勉強も兼ねて、あえてGoで実装してみることにしました。

GoにはCLIツールと相性が良い特徴があり、特に以下の点が実際に使いやすいと感じました。

  • go build で単一バイナリが作れる(依存なしで配布・実行できる)
  • コンパイル言語なので実行が速く、CLIとして使ったときの起動がもたつかない
  • 実行環境ごとに依存パッケージを入れる手間がない

ちなみに自分はGoを書いたことがほぼなく、「Hello world」程度の経験しかありませんでした。なので今回は AIとのバイブコーディングで学びながら進めました。


全体の設計

最初から欲張らず、段階的に育てていく方針にしました。

Step 1: 引数を受け取って表示するだけ
Step 2: 1ファイルだけ変換する
Step 3: 複数ファイルに対応する
Step 4: --all フラグで今いるフォルダを一括変換
Step 5: --watch でダウンロードフォルダを自動監視

Step 1: 引数を受け取るだけのCLI

まずはGoプロジェクトを作ります。

mkdir heic2jpg
cd heic2jpg
go mod init heic2jpg

そして main.go を作成します。

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("args:", os.Args)
}

実行してみます。

go run main.go hello world
# => args: [/tmp/.../main hello world]

ここで学んだこと:os.Args とは何か

os.Args はコマンドラインで渡した値が全部入っているスライス(配列のようなもの)です。

  • os.Args[0] → プログラム自身のパス
  • os.Args[1] 以降 → ユーザーが渡した値

これが「引数を受け取る」の基本です。


Step 2: 1ファイルだけ変換する

次に、実際にHEICファイルをJPEGに変換する処理を加えます。GoからMac標準の sips コマンドを呼びます。

package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("使い方: go run main.go <file.heic>")
        return
    }

    input := os.Args[1]

    // .heic 以外が渡されたら弾く
    if !strings.HasSuffix(strings.ToLower(input), ".heic") {
        fmt.Println("HEICファイルを指定してください")
        return
    }

    // 出力ファイル名を作る(拡張子を .jpg に変える)
    output := strings.TrimSuffix(input, filepath.Ext(input)) + ".jpg"

    // sips コマンドを呼ぶ
    cmd := exec.Command("sips", "-s", "format", "jpeg", input, "--out", output)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    err := cmd.Run()
    if err != nil {
        fmt.Println("変換に失敗しました:", err)
        return
    }

    fmt.Println("変換完了:", output)
}

コードの意味を分解すると:

コード 意味
strings.HasSuffix(strings.ToLower(input), ".heic") 小文字に統一してから末尾が .heic か確認
strings.TrimSuffix(input, filepath.Ext(input)) 拡張子を取り除いた部分だけ取得
exec.Command("sips", ...) Goから外部コマンドを呼ぶ
cmd.Stdout = os.Stdout sipsの出力をそのままターミナルに流す

実行するとこうなります。

go run main.go IMG_0001.heic
# => 変換完了: IMG_0001.jpg

Step 3: 複数ファイル対応

1ファイルずつ指定するのは面倒なので、複数ファイルを一度に渡せるようにします。
(変換処理は関数として切り出しました)

func convertFile(input string) error {
    if !strings.HasSuffix(strings.ToLower(input), ".heic") {
        return fmt.Errorf("HEICファイルではありません: %s", input)
    }

    output := strings.TrimSuffix(input, filepath.Ext(input)) + ".jpg"

    cmd := exec.Command("sips", "-s", "format", "jpeg", input, "--out", output)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    return cmd.Run()
}

func main() {
    if len(os.Args) < 2 {
        fmt.Println("使い方: go run main.go <file1.heic> <file2.heic> ...")
        return
    }

    for _, input := range os.Args[1:] {
        fmt.Println("変換中:", input)

        err := convertFile(input)
        if err != nil {
            fmt.Println("失敗:", err)
            continue  // 失敗しても次のファイルへ
        }

        fmt.Println("完了:", input)
    }
}

ここで学んだこと:for _, v := range の書き方

Goで繰り返し処理をするときの基本形です。

for _, input := range os.Args[1:] {
    // input に1件ずつ入ってくる
}

_ は「インデックスは使わない」という意味です(使わない変数をそのまま書くとGoはエラーを出します)。

実行例:

go run main.go a.heic b.heic c.heic

Step 4: --all フラグで一括変換

*.heic とシェルに展開させる方法もありますが、--all フラグを作って「今いるフォルダの.heicを全部変換」をできるようにしました。

ちなみに *.heic と --all の違いはここにあります:

コマンド 誰がファイルを選ぶ?
heic2jpg *.heic シェル(zsh/bash)が展開して渡す
heic2jpg --all プログラム自身がフォルダを読む

--all の場合、os.ReadDir でフォルダの中身を取得します。

func findHeicFilesInCurrentDir() ([]string, error) {
    entries, err := os.ReadDir(".")  // 今いるディレクトリを読む
    if err != nil {
        return nil, err
    }

    var files []string
    for _, entry := range entries {
        if entry.IsDir() {
            continue  // ディレクトリはスキップ
        }

        name := entry.Name()
        if strings.HasSuffix(strings.ToLower(name), ".heic") {
            files = append(files, name)
        }
    }

    return files, nil
}

main() 側では、最初の引数が --all かどうかで分岐します。

if os.Args[1] == "--all" {
    files, err := findHeicFilesInCurrentDir()
    // ...
    targets = files
} else {
    targets = os.Args[1:]
}

Step 5: --watch で自動変換(一番の目玉)

ここが今回の本題です。ダウンロードフォルダを常時監視して、HEICファイルが来たら自動でJPEGに変換します。

外部パッケージを使う

Goの標準ライブラリだけでフォルダ監視を綺麗に書くのは難しいため、fsnotify という外部パッケージを使います。

go get github.com/fsnotify/fsnotify

これで go.mod にパッケージが追加されます。

watchDir 関数の実装

func watchDir(dir string) error {
    watcher, err := fsnotify.NewWatcher()  // 監視役を作る
    if err != nil {
        return err
    }
    defer watcher.Close()  // 関数が終わったら閉じる

    err = watcher.Add(dir)  // このフォルダを監視対象に追加
    if err != nil {
        return err
    }

    fmt.Println("監視中:", dir)

    for {
        select {
        case event, ok := <-watcher.Events:  // ファイルイベントが来たら
            if !ok {
                return nil
            }

            // 「新しいファイルが作られた」イベントだけ処理
            if event.Op&fsnotify.Create == fsnotify.Create {
                path := event.Name

                if strings.HasSuffix(strings.ToLower(path), ".heic") {
                    fmt.Println("新しいHEICを検知:", path)

                    time.Sleep(1 * time.Second)  // 書き込み完了を待つ

                    err := convertFile(path)
                    if err != nil {
                        fmt.Println("変換失敗:", err)
                    } else {
                        fmt.Println("変換完了:", path)
                    }
                }
            }

        case err, ok := <-watcher.Errors:  // エラーが来たら
            if !ok {
                return nil
            }
            fmt.Println("監視エラー:", err)
        }
    }
}

コードの仕組みを分解:

コード 意味
fsnotify.NewWatcher() フォルダを監視する「監視役」を作る
watcher.Add(dir) このフォルダを監視対象に登録
for { select { ... } } 何かイベントが来るまでずっと待つループ
<-watcher.Events チャネルからイベントを受け取る(Goの並行処理の仕組み)
event.Op&fsnotify.Create 「ファイルが作成された」イベントかどうか確認
time.Sleep(1 * time.Second) ファイルの書き込みが完全に終わるのを待つ

なぜ time.Sleep が必要か:
ファイル作成イベントは、ファイルの書き込みが完了する前に発火することがあります。HEICファイルが転送中の状態で変換しようとすると失敗するため、1秒待ってから処理しています。

デフォルトで ~/Downloads を監視

引数を省略したときに自動で ~/Downloads を監視するようにしました。

if args[1] == "--watch" {
    var dir string

    if len(args) >= 3 {
        dir = args[2]  // 引数があれば指定されたフォルダ
    } else {
        // なければ ~/Downloads をデフォルトに
        home, err := os.UserHomeDir()
        if err != nil {
            fmt.Println("ホームディレクトリ取得失敗:", err)
            return
        }
        dir = filepath.Join(home, "Downloads")
    }
    // ...
}

ここで気づいた落とし穴:

引数で ~/Downloads と渡すと普通に動くのに、なぜコードの中で "~/Downloads" と書いてはいけないのか?

答えは、~/Downloads の展開をやっているのはGoではなくシェル(zsh/bash)だからです。

ターミナルで入力: heic2jpg --watch ~/Downloads
           ↓ シェルが展開
Goに渡る値: /Users/miho/Downloads

Goのコード内で "~/Downloads" と書くと、それはただの文字列なので展開されません。だから os.UserHomeDir() でホームディレクトリを取得して、filepath.Join でパスをつなぐ必要があります。
個人的にも面白い学びでした。


コマンドとして使えるようにする

go run main.go は開発中の実行方法です。普段使いのコマンドにするには、実行ファイルを作ってPATHに置く必要があります。

ビルドする

go build -o heic2jpg

これで heic2jpg という実行ファイルができます。

PATHに置く

mkdir -p ~/bin
ln -s "$(pwd)/heic2jpg" ~/bin/heic2jpg  # シンボリックリンクで参照

echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

ln -s はシンボリックリンクを作るコマンドです。実体はプロジェクトフォルダにある実行ファイルのままで、~/bin/ から参照できるようにしています。開発中に go build しなおしても、リンクはそのまま使えるので便利です。

確認:

which heic2jpg
# => /Users/yourname/bin/heic2jpg

これ以降は、どのフォルダにいても heic2jpg --watch と打つだけで動くようになります。


最終的なコードの全体像

package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "time"

    "github.com/fsnotify/fsnotify"
)

func convertFile(input string) error {
    if !strings.HasSuffix(strings.ToLower(input), ".heic") {
        return fmt.Errorf("HEICファイルではありません: %s", input)
    }

    output := strings.TrimSuffix(input, filepath.Ext(input)) + ".jpg"

    cmd := exec.Command("sips", "-s", "format", "jpeg", input, "--out", output)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    return cmd.Run()
}

func findHeicFilesInCurrentDir() ([]string, error) {
    entries, err := os.ReadDir(".")
    if err != nil {
        return nil, err
    }

    var files []string
    for _, entry := range entries {
        if entry.IsDir() {
            continue
        }

        name := entry.Name()
        if strings.HasSuffix(strings.ToLower(name), ".heic") {
            files = append(files, name)
        }
    }

    return files, nil
}

func watchDir(dir string) error {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        return err
    }
    defer watcher.Close()

    err = watcher.Add(dir)
    if err != nil {
        return err
    }

    fmt.Println("監視中:", dir)

    for {
        select {
        case event, ok := <-watcher.Events:
            if !ok {
                return nil
            }

            if event.Op&fsnotify.Create == fsnotify.Create {
                path := event.Name

                if strings.HasSuffix(strings.ToLower(path), ".heic") {
                    fmt.Println("新しいHEICを検知:", path)
                    time.Sleep(1 * time.Second)

                    err := convertFile(path)
                    if err != nil {
                        fmt.Println("変換失敗:", err)
                    } else {
                        fmt.Println("変換完了:", path)
                    }
                }
            }

        case err, ok := <-watcher.Errors:
            if !ok {
                return nil
            }
            fmt.Println("監視エラー:", err)
        }
    }
}

func main() {
    args := os.Args

    if len(args) < 2 {
        fmt.Println("使い方:")
        fmt.Println("  heic2jpg <file1.heic> <file2.heic> ...")
        fmt.Println("  heic2jpg --all")
        fmt.Println("  heic2jpg --watch [dir]")
        return
    }

    if args[1] == "--watch" {
        var dir string

        if len(args) >= 3 {
            dir = args[2]
        } else {
            home, err := os.UserHomeDir()
            if err != nil {
                fmt.Println("ホームディレクトリ取得失敗:", err)
                return
            }
            dir = filepath.Join(home, "Downloads")
        }

        fmt.Println("監視ディレクトリ:", dir)
        err := watchDir(dir)
        if err != nil {
            fmt.Println("watch失敗:", err)
        }
        return
    }

    var targets []string

    if args[1] == "--all" {
        files, err := findHeicFilesInCurrentDir()
        if err != nil {
            fmt.Println("ファイル一覧の取得に失敗:", err)
            return
        }
        targets = files
    } else {
        targets = args[1:]
    }

    if len(targets) == 0 {
        fmt.Println("対象のHEICファイルが見つかりませんでした")
        return
    }

    for _, input := range targets {
        fmt.Println("変換中:", input)
        err := convertFile(input)
        if err != nil {
            fmt.Println("失敗:", err)
            continue
        }
        fmt.Println("完了:", input)
    }
}

今回学んだことまとめ

Goの基本:

  • os.Args でコマンドライン引数を受け取れる
  • os/exec で外部コマンドをGoから呼べる
  • os.ReadDir でフォルダの中身を取得できる
  • Go の基本構文(変数宣言やfor文の書き方など)

CLIの設計:

  • *.heic(シェルが展開)と --all(プログラムが探索)は責務が違う
  • シェルは ~/Downloads を展開するが、Goのコード内ではただの文字列
  • go run は開発用、go build で実行ファイル化してPATHに置けば本物のコマンドになる

おわりに

iPhoneからの写真転送のたびに感じていた小さなストレスを、今回作成したCLIで解決することができました。
今では heic2jpg --watch を起動しておくだけで、Macに写真が届いた瞬間にJPEGが自動で出来上がっています。

実際に自分の欲しいものを作りながら、AIに聞きつつGoを学べたことは、とても良い学びになりました。

今後も、日常の「1秒の面倒」をCLIで解消していきたいと思います。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?