はじめに
日常的にブログを書く中で、ずっと感じていた小さなストレスがあります。
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で解消していきたいと思います。