🌧️ ASMR環境音動画を無料ローカルで量産する (全2回の第2回):後編 ― launchd無人運用とコールドスタート障害の自己回復 — リーダー×
🌧️

ASMR環境音動画を無料ローカルで量産する (全2回の第2回):後編 ― launchd無人運用とコールドスタート障害の自己回復

#automation#ffmpeg#claudecode#youtube2026-07-12 · 約35

解雇された翌朝、私のMacは誰に頼まれたわけでもなく、静かにASMR動画を一本仕上げていました。

前回(前編)では、ComfyUI × FFmpeg × Freesound APIを組み合わせて、無料ツールだけで長尺ASMR動画を生成するパイプラインの構造を解説しました。後編となる今回は、そのパイプラインをmacOSのlaunchdに乗せて毎日定刻に動かす設計と、Macを起動してもComfyUIが落ちたままになる「コールドスタート問題」をスクリプト自身が自己回復する仕組みを、実コードで見ていきます。

なぜこの仕組みが効くのか

手作業の天井

ASMR環境音動画の30分尺1本を丁寧に作ると、実作業だけで2〜3時間かかります。画像生成のプロンプト調整、BGMのレイヤリング、ループ点の確認、サムネイル制作、YouTubeのメタデータ入力——それぞれは小さな作業でも、積み重なると重いです。

月30本を維持しようとすると、作業だけで月60〜90時間。副業と掛け持ちしていた時期に試みましたが、2週間で崩壊しました。「量産」という言葉は、手を動かす速度を上げることではなく、手を動かさなくても積み上がる状態を作ることだと、そのとき初めて理解しました。会社都合で解雇されて収入がゼロになったとき、私が真っ先に整備したのはこの「環境」でした。

「作業」ではなく「環境」を持つ

自動化の本質は、自分が何もしなくても出力が増え続ける仕組みを一度だけ構築することです。daily.shが実現しているのはまさにこれで、スクリプトが走り切ったとき ~/Desktop/ASMR/<日付>_<テーマ>/ に動画・サムネイル・youtube.md・静止画が揃った状態でアトミックに着地します。翌朝起きて確認するだけでよく、生成中に席を外しても、寝ていても、ファイルが増えていきます。

コードの中の一行がこの思想を象徴しています:

# 冪等性は「その日に1本でもあればskip」(1日1本・テーマ違いでも二重生成しない)
EXIST=$(find "$DEST" -maxdepth 1 -type d -name "${DATE}_*" 2>/dev/null | head -1)
if [ -n "$EXIST" ]; then log "already stocked for $DATE ($EXIST); skip (idempotent)"; exit 0; fi

launchdが7時と14時の2スロットで起動を試みても、当日分がすでに ~/Desktop/ASMR/ にあれば即座に exit 0 で返ります。二重生成は起きません。途中でMacがスリープしても、14時のスロットがリカバリを担います。これが冪等スロット設計の核心です。

無料で成立する理由

このパイプラインは意図的に月額0円で設計しています。

  • 画像生成:ComfyUI(~/dev/comfyui / RealVisXL / MPS)=無料
  • モーション合成:FFmpeg(displace/perlin flicker)=無料
  • 環境音:Freesound API CC0ライセンス=無料(FREESOUND_TOKENは無料登録で発行)
  • アップロード先:YouTube Data API v3=無料枠で十分

唯一必要なのはMac本体の電力と、Google CloudのOAuthクライアント(無料登録・APIキー発行のみ)です。有料SaaS画像生成も、クラウドGPUも、月額サブスクも使っていません。

この設計は「外部サービスの料金改定で仕組みが壊れる」リスクを排除します。ComfyUIもFFmpegもローカルで動く以上、突然の値上げや無料枠廃止で自動化が止まることがありません。収入ゼロになった直後でも、この仕組みだけは動き続けていました。

coverage.csvが不安を消す

月商を維持するには、仕組みが「動いているかどうか不安になって確認しにいく」ループを断ち切る必要があります。coverage.csvへの追記がその設計です:

# ---- 11. カバレッジログ(CSV) ----
COV="$ROOT/coverage.csv"
[ -f "$COV" ] || echo "date,theme,duration_s,sounds,size,status" > "$COV"
echo "$DATE,$THEME_ID,$DUR,$idx,$SZ,OK" >> "$COV"

毎朝このCSVを1行確認するだけで「昨日の7時スロットが成功したか」がわかります。YouTubeアップが成功したときは末尾のstatusフィールドが OK+uploaded に変わります(sed -i '' "s|,OK\$|,OK+uploaded|" で上書き)。不安が減ると、仕組みに余計な手を入れる頻度も減り、安定稼働期間が伸びます。

冪等性が生むもう一つの利点

冪等設計にはバックフィルという副産物もあります。--date オプションで過去日付を指定すれば、同じスクリプトで欠損分を後から補完できます:

./daily.sh --date 2026-06-28   # 欠けていた6/28分を手動で補完
./daily.sh --still scene.png --theme-id rainy_cafe --force  # 既存画像で音だけ再mix

--stillを使うとComfyUIの起動をスキップして既存の静止画を再利用します。ComfyUIのMPS推論が数分かかるため、画像は気に入っているがBGMだけ変えたい、という用途で実際に何度か使いました。このフラグがないとき、通常は ensure_comfyui() が走ります。その関数が後編の主役です。

全体の流れ

パイプライン鳥瞰図

daily.sh が実行するパイプライン全体を順に示します:

launchd (7:00 / 14:00, catch-up)
    │
    ▼
daily.sh
    │
    ├─[lock]──────── mkdir LOCKDIR atomic / ゾンビPID自動回収
    │
    ├─[冪等確認]──── ~/Desktop/ASMR/<DATE>_* 存在? → exit 0
    │
    ├─[ComfyUI]───── down? → 自動起動(.venv mps) → 最大600秒待機
    │
    ├─[テーマ]─────── 日付UNIXtime÷86400 % 7種 → 決定的ローテ
    │
    ├─[1] 画像生成  comfy_gen.py (seed=日付ベース / 最大3リトライ)
    │
    ├─[2] 自動マスク auto_masks.py (rain窓 / fire炎 自動検出)
    │
    ├─[3] 雨変位マップ gen_rain_glass_map.py (16秒ループ / キャッシュ流用)
    │
    ├─[4] ループ動画  render_loop.sh (FFmpeg displace + flicker)
    │
    ├─[5] 音取得+mix  freesound_fetch.py (timeout 240s) + mix_audio.sh
    │
    ├─[6] 30分化      make_full.sh (ループタイル)
    │
    ├─[7] サムネ       make_thumb.py (1280×720)
    │
    ├─[8] メタデータ  make_meta.py → youtube.md
    │
    ├─[9] 検証────── 尺±3秒 / video+audioストリーム / サムネサイズ
    │
    ├─[10] atomic移動 work/_deliver → ~/Desktop/ASMR/<DATE>_<THEME>/
    │
    ├─[11] CSV追記    coverage.csv (date,theme,duration_s,sounds,size,status)
    │
    └─[12] YouTube    非公開アップ (token有時のみ / 失敗してもstock保持)

すべてのステップで die() を使い、失敗した時点で即座に exit 1 します。中途半端な状態のまま次のステップに進みません。~/Desktop/ASMR/ に到達するのは、12ステップ全てが成功した場合のみです。

スクリプト冒頭の工夫

set -uo pipefail
export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8   # launchdの最小環境でも日本語UTF-8を安定処理
cd "$(dirname "$0")"
ROOT="$(pwd)"

set -uo pipefail でパイプ内の失敗も必ず exit 1 に伝播させます。launchdが起動したshellはPATHもロケールも最小限です。日本語テーマ名を含むファイル名やCSV書き込みが文字化けしないよう、冒頭でロケールを強制しています。cd "$(dirname "$0")" でスクリプトと同じディレクトリに移動し、themes.jsonlib/ への相対パスが常に解決できる状態にします。

ロック機構の詳細

LOCKDIR="$ROOT/.daily.lock.d"
acquire_lock(){
  if mkdir "$LOCKDIR" 2>/dev/null; then echo $$ > "$LOCKDIR/pid"; return 0; fi
  local opid; opid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "")
  if [ -n "$opid" ] && ! kill -0 "$opid" 2>/dev/null; then
    log "stale lock (pid $opid dead); reclaiming"; rm -rf "$LOCKDIR"
    if mkdir "$LOCKDIR" 2>/dev/null; then echo $$ > "$LOCKDIR/pid"; return 0; fi
  fi
  return 1
}
if ! acquire_lock; then log "another run holds the lock; exiting"; exit 0; fi
trap 'rm -rf "$LOCKDIR"' EXIT

mkdir はPOSIX上でアトミックです。複数プロセスが同時に試みても成功するのは一つだけ。flock が使えないmacOSのlaunchd環境でも確実に排他制御できます。

ゾンビlockの自動回収がポイントです。前回の実行がクラッシュして LOCKDIR が残ったまま、そのPIDプロセスがすでに死んでいる場合、kill -0 $opid の失敗を検知してlockを再取得します。人間が手動で .daily.lock.d を消さなくても次の起動で自己修復します。

テーマのローテーション

NTHEME=$(python3 -c "import json;print(len(json.load(open('themes.json'))['themes']))")
DAYIDX=$(( $(date -j -f "%Y-%m-%d" "$DATE" +%s 2>/dev/null || date -d "$DATE" +%s) / 86400 ))
PICK=$(( DAYIDX % NTHEME ))
THEME_ID=$(python3 -c "import json;print(json.load(open('themes.json'))['themes'][$PICK]['id'])")

現在 themes.json には7種のテーマが定義されています。日付をUNIXtime÷86400で日数に変換し、テーマ数7で割った余りでテーマを選びます。同じ日付を指定すれば常に同じテーマが選ばれる決定的ローテーションです(--theme-id で上書き可能)。

date -j -f "%Y-%m-%d" はmacOS BSD dateの書式で、Linuxの date -d と互換性がないため 2>/dev/null || date -d "$DATE" +%s でLinux版にフォールバックしています。launchd+macOS前提のスクリプトですが、開発中にLinuxのDockerで動かすこともあるための配慮です。

音源取得のタイムアウト設計

for lic in cc0 any; do
  if timeout 240 python3 "$LIB/freesound_fetch.py" \
      --query "$Q" --minlen 25 --license "$lic" --out "$SRC" \
      >"$WORK/fs_${i}.json" 2>>"$LOG"; then
    fetched=1; break
  fi
done
if [ "$fetched" -eq 1 ]; then MIXARGS+=("$SRC" "$G"); else log "WARN: sound fetch failed: $Q"; fi

FreesoundのプレビューDLエンドポイントは公式にタイムアウトが設定されていません。通信が確立しても、DLが止まったまま無限に待ち続けることがあります。timeout 240(4分)でプロセスごと強制終了し、失敗扱いで次のループへ進みます。

CC0ライセンスで取れなかった場合は any ライセンスにフォールバック(for lic in cc0 any)し、それでも失敗した音源はWARNログに残しつつskipします。全音源が0件になった場合だけ die "no sound sources fetched (won't ship silent)" でパイプライン全体を止めます——無音の動画は出さないという判断です。

Atomic移動と検証による品質保証

# ---- 9. 検証 ----
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO" 2>/dev/null | cut -d. -f1)
WANT=$((MIN*60))
[ -n "$DUR" ] && [ "$DUR" -ge $((WANT-3)) ] && [ "$DUR" -le $((WANT+3)) ] || die "duration check failed ($DUR != $WANT)"
STREAMS=$(ffprobe -v error -show_entries stream=codec_type -of csv=p=0 "$VIDEO" 2>/dev/null | sort | tr '\n' ',')
echo "$STREAMS" | grep -q "audio" && echo "$STREAMS" | grep -q "video" || die "missing stream ($STREAMS)"
TW=$(python3 -c "from PIL import Image;print('x'.join(map(str,Image.open('$THUMB').size)))")
[ "$TW" = "1280x720" ] || die "thumb size $TW != 1280x720"
[ -s "$META" ] || die "meta empty"

ffprobe で尺が MIN*60 ± 3秒 の範囲内かを確認し、videoとaudioの両ストリームが存在するかチェックし、サムネイルが1280×720であることを確認してから移動します。「ffmpegが黙って空動画を作った」「音声だけ欠落した動画がDesktopに着地した」といったサイレント障害を防ぐための設計です。

そして移動はアトミックです:

STAGE="$WORK/_deliver"; mkdir -p "$STAGE"
cp "$VIDEO" "$STAGE/video.mp4"
cp "$THUMB" "$STAGE/thumbnail.png"
cp "$META"  "$STAGE/youtube.md"
cp "$STILL" "$STAGE/scene.png"
mkdir -p "$DEST"
rm -rf "$FINAL"
mv "$STAGE" "$FINAL"

workディレクトリ内で全ファイルを組み立て、最後に mv 一発で着地させます。CPUやGPUが処理中にスリープ→復帰しても、ファイルが中途半端な状態で ~/Desktop/ASMR/ に置かれることはありません。「Desktopに見えているフォルダは必ず完全品」という不変条件を保ちます。

次の章では、この全体フローで最大の障壁となったComfyUIのコールドスタート問題と、launchdへの登録手順を具体的に見ていきます。

実装の詳細

ensure_comfyui():150×4秒=600秒待機の設計

前章で「ensure_comfyui() が後編の主役」と予告しました。関数全体を引用します。

ensure_comfyui(){
  curl -s -m 3 http://127.0.0.1:8188/system_stats >/dev/null 2>&1 && return 0
  log "ComfyUI down; starting (.venv mps)..."
  ( cd "$HOME/dev/comfyui" && PYTORCH_ENABLE_MPS_FALLBACK=1 nohup .venv/bin/python main.py --port 8188 \
      >/tmp/comfyui_asmr.log 2>&1 & )
  for i in $(seq 1 150); do
    sleep 4
    curl -s -m 3 http://127.0.0.1:8188/system_stats >/dev/null 2>&1 && { log "ComfyUI up"; return 0; }
  done
  return 1
}
[ -n "$REUSE_STILL" ] || ensure_comfyui || die "ComfyUI unavailable (could not start)"

まず curl -s -m 3http://127.0.0.1:8188/system_stats を叩きます。3秒以内にレスポンスが返れば起動済みとみなして即 return 0。ポーリングをここで終わらせることで、ほとんどの日(前日に動画生成が走りComfyUIがそのまま起動中の場合)は数ミリ秒で通過します。

ComfyUIが落ちていた場合はサブシェルでバックグラウンド起動します。肝は .venv/bin/python の明示です。python3 ではなく仮想環境のPythonを直接指定しているのは、launchdのPATHが /usr/bin:/bin:/usr/sbin:/sbin 程度の最小構成だからです。python3 を呼ぶとシステムPythonが使われ、ComfyUIの依存(torchsafetensorskornia 等)が入っていないためインポートエラーで即死します。PYTORCH_ENABLE_MPS_FALLBACK=1 はApple SiliconのMPSバックエンドが対応していないオペレーターをCPUにフォールバックさせるための環境変数で、これを忘れると特定レイヤーで RuntimeError: Not implemented for MPS が出てモデルロードが止まります。

nohup でsignal遮断してstdoutを /tmp/comfyui_asmr.log に逃がしています。デーモン化しないことで daily.shEXIT トラップが不用意にComfyUI自体を道連れにするのを防ぎます(trap 'rm -rf "$LOCKDIR"' EXIT はlockディレクトリを消すだけで、バックグラウンドプロセスには影響しません)。

起動後のポーリングは seq 1 150 で150回、sleep 4 で4秒間隔——合計**最大600秒(10分)**待機します。RealVisXLは13GB超のモデルをMPSメモリにロードするため、Macがコールドスタートした朝はモデルがディスクキャッシュにも乗っていない状態から始まります。SSDのシーケンシャルリードが走り、PyTorchのMPSグラフ最適化が走り、ComfyUIのワークフロー登録が走る——この一連の処理が実測で5〜8分かかることがありました。150×4=600秒はその最悪ケースを包含する数字として決定しています。600秒を超えてもHTTPが返らない場合は return 1 で呼び出し元の die "ComfyUI unavailable" に渡し、パイプライン全体を止めます。

--still フラグを渡した場合(既存静止画の再利用)は ensure_comfyui をスキップします:

[ -n "$REUSE_STILL" ] || ensure_comfyui || die "ComfyUI unavailable (could not start)"

画像は変えずBGMだけ差し替えたいときに使うフラグで、ComfyUI起動の10分待機が丸ごと省けます。


launchd登録:7時・14時と遅延キャッチアップ

launchdの設定ラベルは com.lily.asmr-daily(README記載)で、~/Library/LaunchAgents/plist を置いて launchctl load します。StartCalendarInterval を2エントリ設定することで7時と14時の2スロットを確保します。

catch-up は、Macがスリープ中に起動予定時刻を過ぎた場合、次に起動したときに遅延実行してくれる挙動です。StartCalendarInterval はデフォルトでこれが有効なため、7時にスリープしていた場合でも、Macを開いた瞬間にlaunchdが daily.sh を呼び出します。そのとき冪等チェックが走り:

EXIST=$(find "$DEST" -maxdepth 1 -type d -name "${DATE}_*" 2>/dev/null | head -1)
if [ -n "$EXIST" ]; then log "already stocked for $DATE ($EXIST); skip (idempotent)"; exit 0; fi

当日分がDesktopになければそのまま実行し、あれば即 exit 0 で返ります。14時スロットがある理由は、7時スロットが何らかの理由で失敗した日のリカバリを人間が介在せずに担わせるためです。1日に2回チャンスがある設計なので、外出先から戻ってMacを開いたとき(15時など)でも14時分のcatch-upが走ります。

launchd向けの追加設定として EnvironmentVariables キーで HOMEPATH を明示的に注入することを推奨します。daily.sh の冒頭にも書いたとおり、launchdで起動したシェルはユーザーのログインシェルとは別プロセスです。~/.zshrc で設定したnvmパス、brew 系のPATH、FREESOUND_TOKEN などの環境変数は一切引き継がれません。FREESOUND_TOKEN.env ファイルから読み込む設計([ -f "$ROOT/.env" ] && { set -a; . "$ROOT/.env"; set +a; })にしているため、plistの EnvironmentVariables にはPATHと HOME の2つを書けば足ります。

ログは daily.log(スクリプト内でtee追記)と launchd.log(plistの StandardOutPath/StandardErrorPath)の2系統に残ります。launchd.logはlaunchdがdaily.shを起動した時刻とexit codeを記録するため、「daily.shが呼ばれさえしなかったのか、呼ばれたが内部で死んだのか」の切り分けが即できます。


YouTube非公開アップ:OAuthの無人refresh設計

アップロード部分の核は youtube_upload.pyget_creds()upload() です。

def get_creds(interactive=False):
    creds = None
    if os.path.exists(TOKEN):
        creds = Credentials.from_authorized_user_file(TOKEN, SCOPES)
    if creds and creds.valid:
        return creds
    if creds and creds.expired and creds.refresh_token:
        creds.refresh(Request())
        _save(creds); return creds
    if not interactive:
        sys.exit("ERROR: no valid token. Run once: python3 lib/youtube_upload.py --auth")
    ...

token.json が存在して有効ならそのまま使い、期限切れなら creds.refresh(Request()) でアクセストークンを更新してから _save(creds) で上書き保存します。Google OAuthのアクセストークンは有効期限1時間ですが、リフレッシュトークンは無効化されない限り永続します。この3行があることで、初回だけブラウザ同意(--auth)すれば以降は daily.sh が無人でrefreshします。_save() では os.chmod(TOKEN, 0o600) でパーミッションを制限しているため、token.json がgroup/otherに読まれるリスクもありません。

アップロード本体:

media = MediaFileUpload(spec["video"], chunksize=8 * 1024 * 1024, resumable=True,
                        mimetype="video/mp4")
req = yt.videos().insert(part="snippet,status", body=body, media_body=media)
resp = None
while resp is None:
    status, resp = req.next_chunk()
    if status:
        print(f"  upload {int(status.progress()*100)}%", file=sys.stderr)

resumable=True と8MBチャンクで分割送信します。30分のH.264動画は1〜2GBになるため、一括送信するとネットワーク瞬断でゼロからやり直しになります。resumableモードでは途中から再開できるため、自宅WiFiの不安定な深夜帯でも問題が起きにくいです。daily.sh 側では timeout 1200(20分)を被せています:

if timeout 1200 python3 "$LIB/youtube_upload.py" --upload "$FINAL/upload.json" >>"$LOG" 2>&1; then

アップが成功すると daily.log にStudio URLが記録され、coverage.csv のstatusを OK+uploaded に書き換えます:

sed -i '' "s|,OK\$|,OK+uploaded|" "$ROOT/coverage.csv" 2>/dev/null || true

公開は人間の手でします。スクリプトが設定するprivacyStatusは "private" 固定です(spec.get("privacy", "private"))。タイトル・説明・タグ・サムネイルはすでに設定済みなので、Studio上でクリックひとつで公開できる状態になっています。サムネイルは動画アップとは別リクエスト(thumbnails().set())で設定するため、まれにアップは成功したがサムネ設定だけ失敗するケースがあります。その場合は --set-thumb VIDEO_ID thumb.png で後から再設定できます:

def set_thumb(video_id, thumb):
    """既存動画にサムネだけ設定(再アップせず・要確認済みアカウント)"""

この補修コマンドは youtube_upload.py に同梱されています。


私が詰まった話

その1:ComfyUI起動待ちが足りず、毎朝エラーで止まった

最初に ensure_comfyui() を書いたとき、ポーリング回数を45回にしていました。45 × 4秒 = 180秒。「3分待てば十分だろう」という直感です。

Macを使いながらバックグラウンドでComfyUIが立ち上がる場合は確かに3分で間に合うことが多いです。問題はコールドスタート——夜中に電源を落として翌朝7時にlaunchdが起動する状況です。このとき、RealVisXLのsafetensorsファイル(~/dev/comfyui 以下に配置)はディスクキャッシュに一切乗っていません。SSDのシーケンシャルリードが走り、Pythonの起動オーバーヘッドが走り、PyTorchがMPSデバイスを初期化し、ワークフローのノードをロードする——実測でこの全工程が5〜6分かかる日が出てきました。180秒で打ち切り、die "ComfyUI unavailable" に落ちてしまう朝が週に2〜3日続きました。

daily.log を確認すると:

[2026-06-14 07:04:22] ComfyUI down; starting (.venv mps)...
[2026-06-14 07:07:04] FAIL: ComfyUI unavailable (could not start)

7時04分に起動を試み、7時07分(約3分)でタイムアウト。その後14時のcatch-upスロットで再試行するものの、Macを持ち出して外出していたためそちらも実行されず、当日分の生成がゼロになる日が出ました。

ポーリング回数を seq 1 150(600秒)に変えてからコールドスタートの失敗は完全になくなりました。10分待機はスクリプトが固まっているように見えますが、あくまでComfyUI起動を待っている間だけで、その後の生成・ミックス・アップロードは並行して走りません(シリアルです)。コーヒーを淹れて戻ったころには coverage.csvOK が入っています。


その2:freesoundのDLが黙って止まり、daily.shが無限に待った

Freesound APIのプレビューDLエンドポイントには公式のタイムアウトが設定されていません。ヘッダーに Content-Length が返ってきてDLが始まった後、途中でサーバー側が無言で接続を保持したまま止まることがあります。wgetrequests.get(stream=True) で単純にDLすると、接続が「生きている」ためsocket timeoutが発火せず、プロセスが永遠に待ち続けます。

初期実装ではこのケースを想定しておらず、freesound_fetch.py をそのまま呼んでいました。README.md には今でも「fetchはtimeout 90で握り音源欠落はgraceful skip」と書いたままですが、実際の daily.shtimeout 240(4分)になっています:

if timeout 240 python3 "$LIB/freesound_fetch.py" \
    --query "$Q" --minlen 25 --license "$lic" --out "$SRC" \
    >"$WORK/fs_${i}.json" 2>>"$LOG"; then

最初に90秒で設定した理由は「音源は短いプレビューファイルだから余裕だろう」という見込みです。現実は、Freesoundのプレビューサーバーが海外拠点にあり、夜中の自宅回線から見ると400kbps前後になることがあります。25秒以上の音源のプレビューMP3(128kbps)は約400KBですが、それでも転送中に止まると90秒では足りません。

90秒のtimeoutが発火した瞬間に freesound_fetch.py のプロセスが SIGTERM で落とされ、$SRC ファイルが空または不完全な状態で残ります。後続の mix_audio.sh がその不完全ファイルを食ってFFmpegが Invalid data found when processing input で死ぬ——という連鎖が起きました。240秒化+fetchのcc0→anyフォールバックを入れてからこの種の失敗はなくなっています。音源全件が0件になったときだけ die "no sound sources fetched (won't ship silent)" で止めることにして、無音動画が着地するサイレント障害を防いでいます。


その3:launchdのPATHが狭すぎて .venv が解決しなかった

launchdで起動したシェルのPATHは /usr/bin:/bin:/usr/sbin:/sbin です。~/.zshrc に書いたbrew、nvm、Conda等のパスは一切入りません。最初の実装では ensure_comfyui() の中で:

nohup python3 main.py --port 8188 &

と書いていました。launchd経由で呼ばれると python3 がシステムPython(/usr/bin/python3)に解決されます。システムPythonにはtorch等が入っていないため、ComfyUIが起動直後に:

ModuleNotFoundError: No module named 'torch'

で落ちます。しかも nohup でバックグラウンドに飛ばしているため、このエラーは /tmp/comfyui_asmr.log に書き込まれるだけで daily.sh のログには何も出ません。ComfyUIが起動に失敗したことに気づかないまま、600秒ポーリングが続き、最終的に die "ComfyUI unavailable" で止まります。

修正は単純で、.venv/bin/python をフルパスで指定すること:

( cd "$HOME/dev/comfyui" && PYTORCH_ENABLE_MPS_FALLBACK=1 nohup .venv/bin/python main.py --port 8188 \
    >/tmp/comfyui_asmr.log 2>&1 & )

$HOME は launchd でも展開されます(EnvironmentVariablesHOME を設定しているため)。~/dev/comfyui/.venv/bin/python を実質的に使うことになり、依存ライブラリがすべて揃った状態でComfyUIが起動します。

同じ理由で、launchd向けの plist には PATH を明示的に入れています。ffmpeg・ffprobe・python3 が brew 経由でインストールされている場合、plist の EnvironmentVariables/opt/homebrew/bin を含めないとこれらのコマンドも解決しません。daily.shffprobe を呼ぶステップ(検証処理)で command not found になって死ぬ、という失敗も最初の数日で経験しました。


その4:LC_ALL未設定で日本語テーマ名が化け、coverage.csvが壊れた

launchdのシェルはロケールも最小です。LC_ALL が設定されていない状態で日本語を含む文字列をファイル名やCSVに書き込むと、macOSのBSD環境では文字化けが起きます。具体的には、themes.json から取得した jp_title(例:「雨カフェ」)を変数に入れて:

echo "$DATE,$THEME_ID,$DUR,$idx,$SZ,OK" >> "$COV"

と書いたとき、THEME_ID 自体はASCII(rainy_cafe 等)なので問題ありませんが、ログ出力や youtube.md に書き込む JP_TITLE が化けます。daily.log を見ると:

[2026-06-10 07:12:34] === daily start date=2026-06-10 theme=rainy_cafe rain=True -> ...
title: ??? ??? ???

タイトルがクエスチョンマークの羅列になっていました。coverage.csv の status列も正常でしたが、youtube.md の内容が文字化けしているため、手動でコピペするときに直す必要がありました。

daily.sh の2行目に追加したのが:

export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8   # launchdの最小環境でも日本語UTF-8を安定処理

コメントにも残しているとおり、これがないとlaunchd経由の実行だけ日本語が壊れます。ターミナルから手動で ./daily.sh を実行すると ~/.zshrc 経由でロケールが入るため、手元では再現せず、「なぜlaunchdだけ文字化けするのか」の切り分けに時間がかかりました。


この4つの失敗をすべて踏んだあとに出来上がったのが、現在の daily.sh です。コードのコメントに「launchdの最小環境でも〜」「macOS互換 mkdir atomic」「DLハングを防ぐ」と書いてある一行一行が、それぞれ実際に壊れて直した痕跡です。

次回は、7種のテーマ定義ファイル themes.json の設計と、ComfyUIのプロンプトをどう書けば自動マスク(auto_masks.py)が安定して機能するかを掘り下げます。

つまずきポイント

ここまでの構築を通じて踏んだ失敗を、再現しやすい順に整理します。ComfyUI 180→600秒化・Freesound DLハング 90→240秒化・launchd PATH と .venv 解決・LC_ALL 未設定による文字化けの4件はすでに前節で掘り下げたため、それ以外で実際にハマった点を中心に記します。

コールドスタート・モデルロード系

  • PYTORCH_ENABLE_MPS_FALLBACK=1 を忘れると、エラーが daily.sh のログに出ない。ComfyUIをバックグラウンド起動する行:
( cd "$HOME/dev/comfyui" && PYTORCH_ENABLE_MPS_FALLBACK=1 nohup .venv/bin/python main.py --port 8188 \
    >/tmp/comfyui_asmr.log 2>&1 & )

PYTORCH_ENABLE_MPS_FALLBACK=1 がないと、Apple SiliconのMPSが対応していないオペレーターで RuntimeError: Not implemented for MPS が出てモデルロードが途中で止まる。nohup でバックグラウンドに飛ばしているため、このエラーは /tmp/comfyui_asmr.log に落ちるだけで daily.sh のログには何も出ない。ComfyUI up が永遠に返ってこないまま600秒が消費され、最終的に die "ComfyUI unavailable" で止まる——というパターンで2日連続失敗した。

  • daily.shEXIT トラップを踏んでも、ComfyUIは止まらないtrap 'rm -rf "$LOCKDIR"' EXIT はlockディレクトリを消すだけ。nohupdaily.sh のプロセスグループから切り離されているため、daily.shexit 1 してもComfyUIは起動したまま翌朝まで残る。これは意図した設計(翌日の7時スロットでの起動待機を省くため)だが、「なぜComfyUIが死んでいないのか」と最初は混乱した。curl -s -m 3 http://127.0.0.1:8188/system_stats が即座に return 0 するのはこの副産物でもある。

  • comfy_gen.py のタイムアウト --timeout 300 とリトライは独立しているdaily.sh 側はリトライ3回の際に seed を +777 ずつずらす:

for att in 0 1 2; do
  SEED=$(( SEEDBASE + att*777 ))
  if python3 "$LIB/comfy_gen.py" --prompt "$PROMPT" --seed "$SEED" --out "$STILL" --timeout 300 ...; then
    ok=1; break
  fi
  sleep $(( (att+1)*10 ))
done

3回全失敗で die "image generation failed after retries" になる。Seed を変えているため、特定 Seed でモデルがハングする場合に次の試行で抜け出せることが実際にあった。

ファイル・ステート系

  • rm -rf "$WORK" は成功時のみ実行daily.sh の末尾、ログ出力の直前にある:
rm -rf "$WORK"
log "=== daily done"

失敗時は ~/dev/asmr-factory/out/work_YYYY-MM-DD_THEME_ID/ が残る。意図的な設計だが放置するとディスクが圧迫される。still.pngloop.mp4bed.wav が丸ごと残るため、ffprobe out/work_.../video.mp4 でその場でデバッグできる。古いworkは週次で手動削除している。

  • sed -i '' はmacOS BSD sed固有の書式coverage.csv の末尾を書き換えるステップ:
sed -i '' "s|,OK\$|,OK+uploaded|" "$ROOT/coverage.csv" 2>/dev/null || true

Linux環境でテストすると sed -i が正しく、macOSに持ってくると illegal option -- i で落ちる(またはその逆)。|| true でこの行が失敗しても全体を止めないようにしているが、coverage.csvOK+uploaded への書き換えが黙って失敗していることに後から気づくケースがあった。

  • ゾンビlockが残ると永遠にスキップされるacquire_lock()kill -0 による自動回収がないと、前の実行がクラッシュして .daily.lock.d/ が残った場合、次回起動時に another run holds the lock; exiting で即終了してしまう。daily.sh を見ると回収ロジックが明示されている:
if [ -n "$opid" ] && ! kill -0 "$opid" 2>/dev/null; then
  log "stale lock (pid $opid dead); reclaiming"; rm -rf "$LOCKDIR"
  if mkdir "$LOCKDIR" 2>/dev/null; then echo $$ > "$LOCKDIR/pid"; return 0; fi
fi

これを忘れると「なぜ毎朝skipされているのか」が daily.log を見ないとわからない。

  • upload.json のvideo/thumbnailパスは atomic move 後に絶対パスへ上書きするmake_meta.py 生成時点のパスは work/ 以下の相対パスに近い状態になっている。daily.sh はstockへの move 後にPythonで注入し直している:
s['video']='$FINAL/video.mp4'; s['thumbnail']='$FINAL/thumbnail.png'; s['privacy']='private'

この注入を忘れると youtube_upload.pyvideo not found で落ちる。upload.json を手動で確認するときは、videoフィールドが絶対パスになっているかを見る。

YouTube API系

  • サムネイル設定は動画アップと別リクエストyoutube_upload.py ではこれを try/except で捕捉してWARNにとどめている:
try:
    yt.thumbnails().set(videoId=vid, media_body=MediaFileUpload(thumb)).execute()
    print("  thumbnail set")
except Exception as e:
    print(f"  WARN: thumbnail set failed: {e}", file=sys.stderr)

動画アップが成功して coverage.csvOK+uploaded が記録されても、YouTube Studio上でサムネイルが黒い場合はこのパスで失敗している。後から --set-thumb VIDEO_ID thumb.png で再設定できる。サムネイル設定にはYouTubeアカウントの確認済み(電話番号認証済み)が必要で、未確認アカウントでは常にこのAPIが 403 で落ちる。

  • cache_discovery=False を忘れると discovery キャッシュが競合することがあるbuild("youtube", "v3", credentials=get_creds(), cache_discovery=False)cache_discovery=False は地味に重要。launchd経由で複数プロセスが同時に起動するシナリオ(lockクラッシュ→再起動の競合)でキャッシュファイルのロック競合エラーが出た。

  • token.json を誤って削除すると --auth からやり直しになる_save() 内で os.chmod(TOKEN, 0o600) を呼んでいる:

def _save(creds):
    os.makedirs(CRED_DIR, exist_ok=True)
    with open(TOKEN, "w") as f:
        f.write(creds.to_json())
    os.chmod(TOKEN, 0o600)

リフレッシュトークンは1セッションで1回しか発行されないため、~/.youtube/ はTime Machineや暗号化ストレージのバックアップ対象に含めておく。

  • YouTube Data API v3 の日次クォータは10,000ユニット。動画アップロードが1,600ユニット、サムネイル設定が50ユニット消費する。通常の1日1本運用では問題ないが、--date フラグで過去分をバックフィルする場合は6本でクォータ上限に当たる。バックフィルは日をまたいで分散させる。

launchd・シェル環境系

  • StartCalendarInterval のcatch-upは「再起動」では動かないことがある。MacをスリープからOpen中に7時を過ぎた場合はcatch-upが走るが、完全シャットダウン→翌朝電源投入ではcatch-upが発火しないケースがある。Macをスリープ運用にして電源を切らない方が安定する。どうしても切る場合は、手動で ./daily.sh を実行するか、14時スロットに任せる。

  • plistStandardOutPath 親ディレクトリが存在しないとlaunchdがジョブを無視することがあるlaunchd.log が空のままで daily.sh が呼ばれた形跡すらない場合は、plistのログパスの親ディレクトリを確認する。

  • .env の読み込みには set -a; . "$ROOT/.env"; set +a を使うsource .env だけでは set -u と組み合わせたとき空文字変数で unbound variable になることがある。set -a で以降の変数を自動エクスポートにしてから読み込むと、サブシェルで呼んだPythonスクリプトから os.environ["FREESOUND_TOKEN"] が確実に見える。


ベストプラクティス

ここまでの失敗と修正を踏まえて、launchd × ローカルML × 外部API の無人パイプラインを半年間安定運用するなかで実践しているルールを整理します。

1. set -uo pipefail + LC_ALL をスクリプト先頭2行で固める

set -uo pipefail
export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8

pipefail でパイプ内エラーを伝播させ、LC_ALL でlaunchd経由の最小環境でも日本語文字列を安定処理します。-u で未定義変数の参照を即エラーにし、「なんとなく動いているが実は空文字で通っていた」バグを防ぎます。launchd専用のスクリプトを書くときは必ずこの2行から始める癖をつけると、後で文字化けやサイレントエラーに詰まる時間が大幅に減ります。

2. die() を一本化して失敗場所を即特定できるようにする

die(){ log "FAIL: $*"; exit 1; }

daily.log を見たとき「どのステップで止まったか」が FAIL: の後の文言で即判断できます。die "duration check failed ($DUR != $WANT)" なら実際の尺と期待値が数字で見えます。exit 1 を各所に直書きせず、必ず die を通すことで、ログのgrepが FAIL: 一本で効きます。

3. 外部依存には必ず timeout を被せ、値は実測の1.5〜2倍にする

対象timeout値根拠
ComfyUI 疎通確認curl -m 3(3秒)応答は数ms
Freesound DLtimeout 240(4分)海外サーバー400kbps想定
ComfyUI 画像生成--timeout 300(5分)MPS推論実測
YouTube アップtimeout 1200(20分)1〜2GB resumable

「止まる可能性のある外部I/Oは全部止める」が原則です。公式がtimeoutを設定していないAPIは特に要注意です。

4. atomic moveで「Desktopに見えるものは必ず完全品」にする

work/_deliver/ に全ファイルを集めてから mv 一発でstockへ着地させます。mv は同一ファイルシステム内であればinodeの付け替えで実質的にアトミックです。スリープ復帰・クラッシュ・外部API失敗が何が起きても、~/Desktop/ASMR/ に中途半端なフォルダが現れることがありません。

5. launchd向けには .venv/bin/python をフルパスで指定する

launchdのPATHは /usr/bin:/bin:/usr/sbin:/sbin だけです。python3ffmpeg はこのPATHにありません。.venv/bin/activatesource してもサブシェル内の解決にとどまります。$HOME/dev/comfyui/.venv/bin/python のようにフルパスで書き、plistの EnvironmentVariables/opt/homebrew/bin を含めるのが最も確実です。

6. ロックは mkdir アトミック + kill -0 でゾンビ自動回収する

macOSでは flock が使えないため mkdir のアトミック性を利用します。ゾンビlockの自動回収を入れないと、クラッシュ後に人間が .daily.lock.d を手動削除しない限りパイプラインが永遠にスキップされます。

7. 冪等チェックをComfyUI起動前に置く

EXIST=$(find "$DEST" -maxdepth 1 -type d -name "${DATE}_*" 2>/dev/null | head -1)
if [ -n "$EXIST" ]; then log "already stocked for $DATE ($EXIST); skip (idempotent)"; exit 0; fi

当日分があれば即 exit 0。10分のComfyUI起動待機すら発生しません。launchdのcatch-upで何度呼ばれても2回目以降はミリ秒で返ります。「--force フラグなしに再生成が起きない」という不変条件がDesktopの状態を安定させます。

8. 2スロット設計(7時/14時)でリカバリを人間なしに担わせる

7時スロットが失敗しても14時スロットがcatch-upリカバリを担います。外出先から帰宅すると当日分が生成済みになっている、という状態をこの設計で繰り返し経験しています。スロット間隔を7時間空けることで、ComfyUIが重い処理中でも競合しません。

9. coverage.csvで「仕組みへの不安」を毎朝1行で断ち切る

date,theme,duration_s,sounds,size,status
2026-07-03,rainy_cafe,1800,3,412M,OK+uploaded
2026-07-02,forest_night,1801,4,398M,OK+uploaded

OK+uploaded が並んでいる限り触る必要がありません。不安から余計な変更を加えてバグを作るループを断ち切るのが最大の目的です。statusが OK(アップなし)で止まっている場合は ~/.youtube/token.json の有効期限か、YouTube APIクォータを確認します。

10. 検証ステップで「FFmpegが黙って作った空動画」を防ぐ

DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO" 2>/dev/null | cut -d. -f1)
WANT=$((MIN*60))
[ -n "$DUR" ] && [ "$DUR" -ge $((WANT-3)) ] && [ "$DUR" -le $((WANT+3)) ] || die "duration check failed"

FFmpegはエラーコードを返さずに0バイトや10秒の動画を作ることがあります。サムネイルサイズ(1280x720)・video/audioストリームの両存在・メタデータファイルの非空をatomic move前に確認することで、不完全な動画がDesktopに着地するサイレント障害を防ぎます。

11. 失敗時はworkを保持してデバッグに使い、定期的に手動掃除する

失敗時に work/ を消すと再現性がなくなります。still.pngloop.mp4bed.wav が揃った状態でworkが残るため、ffprobe や再生で即確認できます。その代わり ~/dev/asmr-factory/out/ の肥大を週次で確認し、古いworkディレクトリは手動削除します。

12. token.json0o600 で保護しバックアップを取る

os.chmod(TOKEN, 0o600)

~/.youtube/client_secret.jsontoken.json の2ファイルが消えると --auth からやり直しになります。Time Machineや暗号化ストレージのバックアップ対象に入れておき、誤削除に備えます。

13. ドキュメントより実コードが正典・コメントに「なぜ」を残す

README.md には fetchはtimeout 90 と書いてありますが、実際の daily.shtimeout 240 です。運用で変更した値はコードに反映されますが、READMEは追いかけられないことが多いです。他の場所に正典を作らず、コード本体に判断理由を残します:

# timeoutでDLハング(freesoundのプレビューDLにtimeout無し)を防ぐ→失敗扱いで次へ
if timeout 240 python3 "$LIB/freesound_fetch.py" ...

「なぜ240秒なのか」がわかるコメント1行が、将来の自分への最良のドキュメントです。


まとめ

解雇されて収入がゼロになった翌週、真っ先に整備したのはこのパイプラインでした。

毎朝7時にlaunchdが daily.sh を起動し、ComfyUIが自動で立ち上がり、FreesoundからCC0音源を取得し、FFmpegがASMR動画を1本仕上げ、YouTubeへ非公開でアップする——その全工程が完全無人で動いています。眠っていても、外出していても、coverage.csv の行数は増えていきます。

技術的なポイントは2回にわたって解説しましたが、核心は単純です。手を動かす作業を速くするのではなく、手を動かさなくても積み上がる状態を一度だけ作ること。ComfyUIの600秒待機問題も、FreesoundのDLハングも、launchdのPATH問題も、踏んで直す繰り返しで今の形になりました。コードのコメントに残した「なぜ」の一行一行が、その痕跡です。月商120万は、この仕組みが24時間動き続ける状態を半年間維持した結果です。

次回は themes.json の7種テーマ設計と、auto_masks.py が安定して火・雨を検出するためのComfyUIプロンプトの書き方を掘り下げます。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!