Four Ways My Unattended Video Pipeline Died Overnight - and How I Made It Heal Itself
The morning after I lost my job, my Mac finished and filed an ASMR video. Nobody asked it to. It just ran. In the first post, I walked through the structure of the pipeline itself - ComfyUI × FFmpeg × the Freesound API, generating long-form ASMR videos with nothing but free tools. This second post covers the other half: putting that pipeline on macOS launchd so it fires at a fixed time every day, and the self-healing logic that gets the script past the "cold start" problem, where you boot the Mac and ComfyUI simply isn't running. Two numbers do most of the work here: the ComfyUI startup wait went from 180 seconds to 600, and the Freesound download timeout went from 90 seconds to 240. Before those changes, mornings failed 2-3 days a week. Why this setup works The ceiling on manual work Making a single 30-minute ambient ASMR video carefully takes 2-3 hours of hands-on time. Tuning image-generation prompts, layering the BGM, checking the loop points, building the thumbnail, filling in YouTube metadata - each step is small, but they stack up. Trying to hold 30 videos a month means 60-90 hours of pure labor. I attempted it while holding a side job, and it collapsed in two weeks. That was the first time I understood that "scaling output" isn't about moving your hands faster - it's about building a state where output accumulates without your hands at all. When I was laid off and my income went to zero, the first thing I rebuilt was this environment. Own an environment, not a workflow The essence of automation is constructing, exactly once, a mechanism where output keeps growing while you do nothing. That's precisely what daily.sh delivers: when the script finishes, ~/Desktop/ASMR/ _ / lands atomically with the video, thumbnail, youtube.md, and still image all in place. I just check it the next morning. Whether I step away mid-generation or I'm asleep, the files keep piling up. One line in the code embodies the whole philosophy: # 冪等性は「その日に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 Even if launchd tries to fire in both the 07:00 and 14:00 slots, the script returns immediately with exit 0 when today's directory already exists under ~/Desktop/ASMR/ . No double generation. If the Mac sleeps partway through, the 14:00 slot handles recovery. That's the core of the idempotent-slot design. Why it costs ¥0/month This pipeline is deliberately designed to cost nothing monthly. - Image generation: ComfyUI ( ~/dev/comfyui / RealVisXL / MPS) = free - Motion synthesis: FFmpeg (displace / perlin flicker) = free - Ambient sound: Freesound API, CC0-licensed = free ( FREESOUND_TOKEN is issued on free signup) - Upload target: YouTube Data API v3 = the free quota is plenty The only things I actually need are electricity for the Mac itself and a Google Cloud OAuth client (free registration, just issuing an API key). No paid SaaS image generation, no cloud GPU, no monthly subscription. This design eliminates the risk of "an external service changes its pricing and my machinery breaks." Since ComfyUI and FFmpeg both run locally, a sudden price hike or the death of a free tier can't stop the automation. Even right after my income dropped to zero, this one thing kept running. coverage.csv kills the anxiety To sustain monthly revenue, you have to break the loop where you get anxious about whether the machinery is still working and go check on it. Appending to coverage.csv is the design that does that: # ---- 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" One glance at a single line of this CSV each morning tells me whether yesterday's 07:00 slot succeeded. When the YouTube upload succeeds, the trailing status field changes to OK+uploaded (overwritten via sed -i '' "s|,OK$|,OK+uploaded|" ). Less anxiety means fewer unnecessary edits to the machinery, which means longer stretches of stable operation. The other benefit of idempotency Idempotent design also gives you backfill as a byproduct. Point the --date option at a past date and the same script fills in the gap after the fact: ./daily.sh --date 2026-06-28 # 欠けていた6/28分を手動で補完 ./daily.sh --still scene.png --theme-id rainy_cafe --force # 既存画像で音だけ再mix --still skips launching ComfyUI and reuses an existing still image. Because ComfyUI's MPS inference takes several minutes, I've actually used this a few times when I liked the image but wanted different BGM. Without that flag, ensure_comfyui() runs as usual - and that function is the star of this post. The whole flow Bird's-eye view of the pipeline Here is everything daily.sh executes, in order: launchd (7:00 / 14:00, catch-up) │ ▼ daily.sh │ ├─[lock]──────── mkdir LOCKDIR atomic / ゾンビPID自動回収 │ ├─[冪等確認]──── ~/Desktop/ASMR/ _ 存在? → 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/ _ / │ ├─[11] CSV追記 coverage.csv (date,theme,duration_s,sounds,size,status) │ └─[12] YouTube 非公開アップ (token有時のみ / 失敗してもstock保持) Every step uses die() and hits exit 1 the instant something fails. It never proceeds to the next step in a half-finished state. Nothing arrives in ~/Desktop/ASMR/ unless all 12 steps succeeded. Small details at the top of the script 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 guarantees that failures inside a pipe propagate to exit 1 . The shell launchd spawns has a minimal PATH and locale. So that filenames containing Japanese theme names and CSV writes don't get mangled, the locale is forced at the very top. cd "$(dirname "$0")" moves into the script's own directory so relative paths to themes.json and lib/ always resolve. Details of the lock mechanism 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 is atomic under POSIX. Even if several processes try at once, exactly one succeeds. That gives reliable mutual exclusion even in a macOS launchd environment where flock isn't available. Automatic reclamation of zombie locks is the key part. If a previous run crashed and left LOCKDIR behind while that PID's process is already dead, the failure of kill -0 $opid is detected and the lock is re-acquired. No human has to manually delete .daily.lock.d ; the next run self-heals. Theme rotation 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 currently defines 7 themes. The date is converted to a day count via UNIX time ÷ 86400, and the theme is chosen by the remainder modulo the theme count of 7. Specify the same date and you always get the same theme - a deterministic rotation (overridable with --theme-id ). date -j -f "%Y-%m-%d" is macOS BSD date syntax and is incompatible with Linux's date -d , so 2>/dev/null || date -d "$DATE" +%s falls back to the Linux version. The script assumes launchd + macOS, but this is a concession to occasionally running it in Docker on Linux during development. Timeout design for sound fetching 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's preview download endpoint has no officially configured timeout. Even after the connection is established, the download can stall and wait forever. timeout 240 (4 minutes) kills the whole process and moves on to the next loop iteration, treating it as a failure. If nothing can be fetched under CC0, it falls back to the any license (for lic in cc0 any ); sources that still fail are skipped with a WARN in the log. Only when all sources come back empty does the whole pipeline stop with die "no sound sources fetched (won't ship silent)" - the judgment being that I don't ship silent videos. Quality assurance via atomic move and verification # ---- 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" Before moving
Comments
No comments yet. Start the discussion.