DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Zero GPU Cost and 4-Minute Daily Runs — Making Real Rain Fall on a Still Image with ffmpeg displace

A 30-minute ASMR rain video costs me nothing but electricity, and the daily job that builds it dropped from 7–8 minutes to a little over 4. No video-generation model, no GPU rental, no editing. One still image, a physics simulation of raindrops written in Python, and ffmpeg's displace filter.

For context on why I built this: in my second year of university I was making ¥100k a month, stacked side jobs until it was ¥600k, then lost it all at once when a company let me go. Over the next six months I built an autonomous setup centered on Claude Code, and I'm now at ¥1.2M a month in revenue. One of the pillars holding that up is a system that posts an ASMR video to YouTube every single day without me doing anything.

Why this works

ASMR channels have one property that sets them decisively apart from other genres: watch time per view is absurdly long. People leave it running on a sleepless night, or play it for hours while studying. If viewers stay to the end of a 30-minute video, the ad revenue math changes fundamentally. At the same subscriber count, it's not unusual to earn 2–4× what a short-form entertainment channel makes.

The problem was supply cost. Almost every ASMR channel that gets results posts daily. Hand-editing a 30-minute video every day isn't realistic. Trying to render realistic rain with AI video generation (Sora, Runway Gen-3, etc.) runs several hundred to several thousand yen per video. Since I want at least 30 videos stocked ahead to make this a revenue pillar, that price was a non-starter.

The insight that flipped it was simple. What ASMR video asks for is not dynamic cutting — it's the stillness of raindrops crawling slowly down a window. The camera doesn't move. The scene doesn't change. All you need is the texture of a static interior where only the droplets creep along.

You can build that from a still image.

I take a single still from RealVisXL (an image model running on local ComfyUI), generate displacement maps from a raindrop physics simulation written in Python, and composite them with ffmpeg's displace filter. No video model. Zero GPU spend. The only cost of generation is CPU time.

The other core piece is the seamless loop. In ~/dev/asmr-factory/daily.sh the setting is LOOPSEC=16 — a 16-second loop video that make_full.sh tiles out to 30 minutes. A 16-second cycle means about 112 seams inside a 30-minute video. If those seams are visible, the comment section fills with complaints. So the displacement maps are designed periodically so that the displacement of the first frame and the last frame match exactly. That's what fundamentally separates this from a naive "random raindrop animation," and it's the single most important technical point across this series.

Why I locked the composition to "warm light + a window"

The README says: "Themes: 7, all unified around a warm-light indoor composition with a window." Why unify?

Automatic masking (lib/auto_masks.py) detects the window region and the fire region and decides where the rain effect and the flame flicker get applied. That auto-detection loses accuracy when the composition isn't stable. There are 7 themes — "café window seat," "study with a fireplace," "Japanese room with rain outside," and so on — but by constraining them all to a window always visible in frame plus a warm light source, mask accuracy stabilized to a practical level. Narrowing the themes wasn't an aesthetic call; it was a design decision that makes the automation possible.

Six months of unattended operation

The launchd config in ~/dev/asmr-factory/ (com.lily.asmr-daily) fires daily.sh at 7:00 and 14:00 every morning. If the 7:00 run completes, the 14:00 one is skipped (idempotency by design). All I do is open YouTube Studio the next morning and press publish. Everything else is fully automatic.

The overall flow

Here's the whole pipeline.

ComfyUI (RealVisXL @ 127.0.0.1:8188)
    │  still.png ─ 1024×576px
    ▼
lib/auto_masks.py
    │  win_mask.png  (窓領域マスク)
    │  fire_mask.png (炎領域マスク・テーマによりスキップ)
    ▼
lib/gen_rain_glass_map.py      ← ★ 今回の主役
    │  maps/daily_16s/map_0001.png
    │  maps/daily_16s/map_0002.png
    │  … (24fps × 16s = 384枚)
    │  ※ 初回生成後はキャッシュ使い回し
    ▼
lib/render_loop.sh             ← ★ 今回の主役
    │  loop.mp4 (16秒シームレスループ)
    ▼
lib/freesound_fetch.py  +  lib/mix_audio.sh
    │  bed.wav (CC0音源 / loudnorm -20LUFS / 2分尺)
    ▼
lib/make_full.sh
    │  video.mp4 (30分・ループをタイル)
    ▼
lib/make_thumb.py  +  lib/make_meta.py
    │  thumbnail.png (1280×720) / youtube.md
    ▼
lib/youtube_upload.py
       YouTube「非公開」アップ (公開は人間が確認して押す)
Enter fullscreen mode Exit fullscreen mode

Let's walk through each step alongside the code in daily.sh.

Step 1: ComfyUI image generation

SEEDBASE=$(( $(date -j -f "%Y-%m-%d" "$DATE" +%s 2>/dev/null \
               || date -d "$DATE" +%s) % 100000 ))
ok=0
for att in 0 1 2; do
  SEED=$(( SEEDBASE + att*777 ))
  log "comfy_gen attempt $att seed=$SEED"
  if python3 "$LIB/comfy_gen.py" \
       --prompt "$PROMPT" --seed "$SEED" --out "$STILL" \
       --timeout 300 >>"$LOG" 2>&1; then
    ok=1; break
  fi
  sleep $(( (att+1)*10 ))
done
[ "$ok" -eq 1 ] || die "image generation failed after retries"
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L101–111)

The seed is based on the date's UNIX timestamp modulo 100000. Shifting it by +777 on each retry means the same date still gets a different seed on every attempt. If ComfyUI itself is down, the ensure_comfyui() function at daily.sh L50–60 tries to start it. If it doesn't come up after 150 attempts (about 10 minutes), the script aborts safely via die.

Steps 2–3: Mask detection and displacement map generation

# ---- 3. 雨マップ(サイズ固定なのでキャッシュ流用) ----
LOOPSEC=16
MAPDIR="$ROOT/maps/daily_${LOOPSEC}s"
if [ ! -f "$MAPDIR/map_0001.png" ]; then
  log "generating rain map (cache)"
  mkdir -p "$MAPDIR"
  python3 "$LIB/gen_rain_glass_map.py" \
    --w 1024 --h 576 --fps 24 --seconds "$LOOPSEC" \
    --drops 46 --out "$MAPDIR" --seed 7 >>"$LOG" 2>&1 \
    || die "rain map gen failed"
fi
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L121–127)

This is the most important part of the whole series. Look at the condition if [ ! -f "$MAPDIR/map_0001.png" ]. Displacement map generation runs exactly once, ever — from the second run onward the entire maps/daily_16s/ directory is reused.

What the parameters mean:

  • --fps 24 --seconds 16 → generates 384 displacement maps total (24×16 = 384)
  • --drops 46 → the number of raindrops on screen at once. 46 balances density against processing speed
  • --seed 7 → reproduces the same map every time (deterministic, reproducibility guaranteed)
  • --w 1024 --h 576 → matches ComfyUI's output resolution (mismatch makes displace misalign)

I'll dig into what gen_rain_glass_map.py actually does in the second half, but in one line: it's a script that uses a physical model of a window-glass surface to generate 384 grayscale PNGs — displacement maps designed periodically so the first and last frames' displacement amounts match.

Step 4: ffmpeg compositing in render_loop.sh

# ---- 4. ループ動画(モーション) ----
LOOP="$WORK/loop.mp4"
WIN_MASK_ENV=""; FIRE_MASK_ENV=""
[ "$HAS_RAIN" = "True" ] && WIN_MASK_ENV="$WORK/win_mask.png"
[ "$HAS_FIRE" = "True" ] && FIRE_MASK_ENV="$WORK/fire_mask.png"
WIN_MASK="$WIN_MASK_ENV" FIRE_MASK="$FIRE_MASK_ENV" RAIN_OP=0.8 \
  bash "$LIB/render_loop.sh" "$STILL" "$MAPDIR" "$LOOPSEC" "$LOOP" \
    >>"$LOG" 2>&1 || die "render_loop failed"
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L129–135)

RAIN_OP=0.8 is the opacity of the rain effect. At 1.0 the displacement gets so strong the glass looks warped; at 0.6 the rain is too faint. Experiments landed on 0.8 as the most natural. render_loop.sh receives this as an environment variable and expands it into the parameters of ffmpeg's displace filter.

HAS_RAIN and HAS_FIRE come from the theme definitions in themes.json. The "fireplace study" theme is HAS_FIRE=True and HAS_RAIN=False; "rainy café" is HAS_RAIN=True and HAS_FIRE=False; "rainy fireplace" is True for both. Processing branches on whether the mask files exist, so no unnecessary filters get inserted.

Steps 5–6: CC0 audio and stretching to 30 minutes

for lic in cc0 any; do
  if timeout 90 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
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L146–152)

Audio comes from the Freesound API, preferring the CC0 license (falling back in the order cc0any). The timeout 90 is applied manually because Freesound's preview download has no timeout of its own. The multiple sources fetched get mixed and normalized to loudnorm -20LUFS in mix_audio.sh. Without unified LUFS the volume swings wildly from video to video, so this step can't be skipped.

make_full.sh takes the loop video and the audio and tiles them out to 30 minutes (default MIN=30). A 16-second loop × roughly 112.5 repetitions = 30 minutes. ffmpeg's -stream_loop option repeats the video, and the audio is explicitly cut to MIN minutes rather than relying on -shortest.

Steps 7–9: Thumbnail, metadata, validation

# ---- 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"
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L179–186)

Four checks run before output. ① The video duration is 30 minutes ±3 seconds. ② Both a video stream and an audio stream exist. ③ The thumbnail is 1280x720. ④ The metadata file isn't empty. If even one fails, die kills the process, the atomic move described below never runs, and nothing reaches the Desktop. This is where the worst case — "a broken video gets uploaded to YouTube" — is blocked.

Step 10: Atomic stock and idempotency

# ---- 10. atomic stock ----
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"
Enter fullscreen mode Exit fullscreen mode

(from daily.sh L189–197)

Deliverables are gathered into _deliver/ inside the working directory and then moved to the final destination with mv (atomic). Even if the process dies mid-copy, no half-finished directory is left on the Desktop.

Idempotency is guaranteed by the check at L86–88.

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
Enter fullscreen mode Exit fullscreen mode

If even one item exists for that day, it exits immediately regardless of theme. Even though launchd fires twice at 7:00 and 14:00, no double generation occurs.

Step 12: Uploading to YouTube as private

media = MediaFileUpload(
    spec["video"],
    chunksize=8 * 1024 * 1024,   # 8MBチャンク
    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)
Enter fullscreen mode Exit fullscreen mode

(from lib/youtube_upload.py L73–80)

Because it uses resumable upload, a dropped connection mid-transfer can be resumed. chunksize=8 * 1024 * 1024 (8MB units) is tuned to send 30-minute videos (roughly 800MB–1GB) reliably. On success it logs the YouTube Studio URL (L91: https://studio.youtube.com/video/{vid}/edit) and updates the coverage.csv record to OK+uploaded.

What matters is that a failed upload never deletes the stock (the deliverables in ~/Desktop/ASMR/) — see daily.sh L206–226. Expired token, network down, whatever the reason, the local video.mp4 stays. You can just run python3 lib/youtube_upload.py --upload upload.json manually later.


Next time I'll dig into the physics simulation itself in lib/gen_rain_glass_map.py (droplet spawning, gravity, the window-glass surface-tension model, and the mathematical design that ties 384 frames into a cycle), plus the ffmpeg displace filter syntax in lib/render_loop.sh.

Implementation details

Why the displacement map needs a three-layer RGB structure

The header comment in lib/gen_rain_glass_map.py spells out the structure of the output PNG.

R = x-displacement  (128 = neutral, refraction toward droplet center)
G = y-displacement  (128 = neutral)
B = specular highlight (0 = none, bright = glint on the droplet)
Enter fullscreen mode Exit fullscreen mode

A typical displacement map is a single grayscale image where "brighter pushes further," but that can't control horizontal and vertical motion at the same time. This pipeline assigns the R and G channels to independent displacement axes and packs the light reflection (specular glint) into the B channel, cramming three physical quantities into one PNG.

Here's where render_loop.sh unpacks it.

[1:v]setsar=1,split=3[m1][m2][m3];
[m1]extractplanes=r[xm];
[m2]extractplanes=g[ym];
[m3]extractplanes=b,format=gbrp[spec];
[todisp][xm][ym]displace=edge=smear,format=gbrp[disp];
[disp][spec]blend=all_mode=screen:all_opacity=0.85,format=gbrp[raineff];
Enter fullscreen mode Exit fullscreen mode

(from lib/render_loop.sh L36–41)

split=3 branches the same frame into three streams, and extractplanes=r/g/b pulls each channel out as grayscale. R (xm) and G (ym) become the X and Y displacement sources for ffmpeg's displace filter, and B (spec) is additively composited on top with blend=all_mode=screen to become the white highlight of light.

Notice that format=gbrp shows up everywhere. ffmpeg's displace filter hates YUV-family color spaces. It requires GBRP — an uncompressed format with the G, B, and R channels laid out planar — and if you omit it you get [Parsed_displace] incompatible pixel format at runtime and everything stops (more on this below).

The math that makes the seamless loop work

More important than apparent smoothness is that it cycles exactly every 16 seconds with zero seam. The core of that is the velocity design at gen_rain_glass_map.py L38–44.

for _ in range(args.drops):
    n = int(rng.integers(1, 3))        # full wraps over T -> loop
    r = float(rng.uniform(3, 9))
    drops.append(dict(
        ...
        speed=n * span / T,
        ...
    ))
Enter fullscreen mode Exit fullscreen mode

(from lib/gen_rain_glass_map.py L37–44)

span = H + 2 * args.margin (576 + 80 = 656 pixels) is "the travel distance of one full cycle" — the screen height plus top and bottom margins. n is an integer, 1 or 2. Since speed = n * span / T, the distance each droplet has traveled after 16 seconds is speed * T = n * span.

Look at the per-frame position calculation.

cy = (d["y0"] + d["speed"] * t) % span - args.margin
Enter fullscreen mode Exit fullscreen mode

(from lib/gen_rain_glass_map.py L71)

At t = T, d["speed"] * T = n * span, so the result of the modulo equals d["y0"] % span. In other words, the positions at t=0 and t=T match exactly. That's the mathematical basis of the seamless loop.

"Wouldn't randomizing the speeds make the drops move independently and look more realistic?" That's what I thought at first too, and I tried it. The result was a disaster. Drops teleport at the seam. More on that in the "where I got stuck" section below.

The horizontal wobble satisfies the periodicity constraint too

Raindrops running down a window don't fall in straight lines. Uneven surface tension makes them meander left and right. The wobamp and wobn parameters reproduce that.

cx = d["x0"] + d["wobamp"] * math.sin(
    2 * math.pi * d["wobn"] * t / T + d["wobph"]
)
Enter fullscreen mode Exit fullscreen mode

(from lib/gen_rain_glass_map.py L72)

At t = 0 the phase is d["wobph"]; at t = T it's 2 * pi * d["wobn"] * 1 + d["wobph"]. Since wobn is an integer of 1 or 2, 2 * pi * wobn is either or . The period of sine is , so the X coordinates at t=0 and t=T always match. The initial phase wobph is randomized over [0, 2π) so each drop appears to meander differently.

Hero drops and trails create the "ASMR feel"

If all 46 drops are the same small size, the screen gets too busy and stops feeling visually calm. Footage meant to pull you toward sleep needs contrast between "large, slow lead droplets" and "fine droplets clinging to the background."

n_hero = args.hero if args.hero >= 0 else max(3, args.drops // 8)
for _ in range(n_hero):
    r = float(rng.uniform(14, 26))      # fat hero droplet
    drops.append(dict(
        ...
        r=r, speed=1 * span / T,        # n=1: one slow descent per loop
        strength=float(rng.uniform(1.3, 1.8)),
        trail=float(rng.uniform(0.8, 1.0)), glint=1.0,
    ))
Enter fullscreen mode Exit fullscreen mode

(from lib/gen_rain_glass_map.py L51–61)

Against a 3–9px radius for normal drops, hero drops are 14–26px. Their strength is 1.3–1.8 versus 0.7–1.1, so the lens effect is much stronger. With --drops 46 there are 46 normal drops plus max(3, 46 // 8) = 5 hero drops, for 51 drops coexisting on screen.

A feature unique to hero drops is the trail.

if d["trail"] > 0:
    tpad_x = max(0, int(cx - 2)); tpad_x1 = min(W, int(cx + 2))
    ty0 = max(0, int(cy - r * 6)); ty1 = max(0, int(cy))
    if tpad_x1 > tpad_x and ty1 > ty0:
        tsx = xx[ty0:ty1, tpad_x:tpad_x1] - cx
        tdist = np.abs(tsx)
        tfall = np.clip(1.0 - tdist / 2.0, 0, 1)
        vfade = np.clip((yy[ty0:ty1, tpad_x:tpad_x1] - (cy - r * 6)) / (r * 6), 0, 1)
        dx[ty0:ty1, tpad_x:tpad_x1] += -(np.sign(tsx)) * tfall * vfade * (args.disp * 0.25) * d["trail"]
Enter fullscreen mode Exit fullscreen mode

(from lib/gen_rain_glass_map.py L95–103)

It sets a thin column 4px wide (cx±2) and r*6 tall directly above the droplet, and applies a faint horizontal displacement (disp * 0.25) to it. This mimics the state where water has passed through, leaving the glass surface slightly wet and a subtle refraction behind. vfade is a gradient where displacement is largest near the droplet and approaches zero with distance.

Dynamically assembling the ffmpeg filter graph

A distinctive part of render_loop.sh is that the filter graph string is assembled dynamically based on whether the WIN_MASK and FIRE_MASK environment variables are set.

FC="[0:v]format=gbrp,setsar=1[still];"
CUR="still"

if [ -n "$WIN_IN" ]; then
  FC+="[1:v]setsar=1,split=3[m1][m2][m3]; ..."
  CUR="rained"
fi

if [ -n "$FIRE_IN" ]; then
  FC+="[${CUR}]split=2[fb][ff]; ..."
  CUR="lit"
fi

FC+="[${CUR}]geq=r='r(X,Y)*${GFLK}':... ,format=yuv420p[vout]"
Enter fullscreen mode Exit fullscreen mode

(from lib/render_loop.sh L30–54)

The CUR variable tracks the pipeline's "current output label." Insert the rain effect and CUR updates from still → rained, so the flame flicker that follows takes rained as its input. Skip both and you end up applying only the global flicker to still.

Look at the global flicker expression.

C1=$(awk "BEGIN{printf \"%d\", 3*${LOOPSEC}}")   # = 48
C2=$(awk "BEGIN{printf \"%d\", 7*${LOOPSEC}}")   # = 112
GFLK="(0.975+0.018*sin(2*PI*${C1}*T/${LOOPSEC})+0.010*sin(2*PI*${C2}*T/${LOOPSEC}))"
Enter fullscreen mode Exit fullscreen mode

(from lib/render_loop.sh L18–20)

T is ffmpeg's built-in variable for frame time. Using the coefficients C1 = 3 * 16 = 48 and C2 = 7 * 16 = 112, brightness is modulated by the sum of 3Hz and 7Hz sine waves. Both sine waves complete a full cycle between t = 0 and t = LOOPSEC = 16, so the flicker connects seamlessly too. The amplitudes 0.018 and 0.010 represent the extremely faint pulsing of an indoor bulb — an intensity chosen experimentally to sit right at the edge of what the eye consciously follows.

The flame flicker uses the same frequencies with larger amplitudes and a phase offset of 1.7 to give the modulation an organic mismatch.

FFLK="(0.78+0.15*sin(2*PI*${C1}*T/${LOOPSEC})+0.07*sin(2*PI*${C2}*T/${LOOPSEC}+1.7))"
Enter fullscreen mode Exit fullscreen mode

(from lib/render_loop.sh L21)

The base value 0.78 is the coefficient that drops the region under the flame's influence to 78% of normal brightness. In fireplace themes, part of the screen gains a "breathing" motion where light and shadow alternate under the flickering firelight.


Where I got stuck

Stuck 1: Omitting format=gbrp produced nothing on screen

The first filter_complex I wrote was simple.

[0:v][1:v][2:v]displace=edge=smear[out]
Enter fullscreen mode Exit fullscreen mode

It produced video output, but the screen was filled with a flat dark green. Nothing appeared in the log. Raising it to -loglevel info revealed incompatible pixel formats in filter chain.

The cause is that displace can't take input as YUV420P. The still is JPEG-derived YUV, the displacement map is an RGB PNG, and mixing those two streams into displace makes ffmpeg attempt an internal format negotiation and fail.

The fix is to apply format=gbrp right after loading the displacement map, and convert the still side the same way before passing anything to displace. That's exactly what [0:v]format=gbrp,setsar=1[still] at render_loop.sh L30 and the R/G/B expansion following [1:v]setsar=1 at L36 are for. Without format=gbrp the still's colors skew green, and since it fails silently, the cause is extremely hard to find.

Stuck 2: Raindrops teleported at the loop seam

In the first implementation I made speed a random float, something like rng.uniform(20, 80) pixels per second, on the reasoning that "moving independently must look more realistic."

Checking the generated video in its 30-minute form, it was obvious that every raindrop jumped instantaneously every 16 seconds. Visually it was a jarring hitch like dropped frames at high speed — repeated 112 times over quiet music. The worst possible outcome.

The cause is what I explained above: the drop positions at t=0 and t=T don't match. Adding the constraint speed = n * span / T — "an integer number of full spans of travel" — solved it the moment it went in.

As a side effect, drop speed variation is limited to two kinds, n=1 or n=2, but the radius spread (3–26px) and the differences in wobble amplitude more than compensate for realism.

Stuck 3: The mask was black-and-white inverted for maskedmerge

I wrote auto_masks.py on the assumption that the window mask is "white for the window area, black elsewhere." But checking the spec of ffmpeg's maskedmerge filter, the correct behavior is that the whiter the third input, the more the second input (the effect side) is used.

In the first test, the raindrop displacement landed outside the window glass (walls, ceiling), and only the inside of the window had no effect. Visually the result was exactly backwards: "the wall warps and the window stays still."

I checked the mask generation logic in auto_masks.py and confirmed it paints the window region 255 (white) and non-window 0 (black). The problem wasn't how I passed it to ffmpeg — it was the argument order of maskedmerge.

[base][raineff][winmask]maskedmerge
Enter fullscreen mode Exit fullscreen mode

maskedmerge takes its inputs in the order [base video][effect video][mask]. I had originally passed [raineff][base][winmask], so base and effect were swapped during mask compositing. Fixing the argument order was the whole fix. ffmpeg's documentation is thin on argument order; I only noticed after reading the official samples.

Stuck 4: Freesound downloads hung forever

The initial audio-fetching implementation had no timeout, and a job launched by launchd at 7:00 was still stuck past noon. The cause was a case where Freesound's preview download URL starts returning content but the server keeps the session alive without ever sending the terminator. requests.get sets no timeout by default, so the download continues forever.

The timeout 90 at daily.sh L148 exists to prevent this.

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

(from daily.sh L148)

It kills the whole process at 90 seconds. A failed audio slot is recorded with log "WARN: sound fetch failed: $Q", and processing continues as long as at least one other slot succeeded (the [ "$idx" -ge 1 ] check at L161). It's a minimum guarantee to avoid the one thing I can't have — "a silent video with zero audio goes up on YouTube" — and the call is to ship even if only one audio source came through.

Stuck 5: Regenerating the displacement maps daily choked the CPU

The initial design regenerated the rain maps specifically for each day's still. It took me two weeks to notice: "the resolution is fixed, so why regenerate every time?"

Generating 384 PNGs takes about 3 minutes on my M1 MacBook (CPU mode). That was added to every day's generation cost, making the whole thing take 7–8 minutes. And the rain maps have nothing whatsoever to do with the content of the still. As long as the size (1024×576), the length (16 seconds), and the seed (7) are the same, the maps that come out are identical no matter which theme generated them.

The if [ ! -f "$MAPDIR/map_0001.png" ] at daily.sh L123 makes the condition false after the first generation, so it's skipped. Three minutes vanished from the CPU load on day two onward. Since that change, daily.sh runs in the low 4-minute range on average.


Next time I'll cover the segmentation implementation in lib/auto_masks.py, the audio-drift countermeasures when lib/make_full.sh tiles the 16-second loop out to 30 minutes, and the actual channel revenue numbers.

Gotchas (additional round)

On top of the five detailed above (format=gbrp, loop-seam teleporting, maskedmerge argument order, the infinite Freesound hang, and daily regeneration of the displacement maps), here are the landmines I stepped on in real operation. Every one of them will very likely bite you if you run this without reading the code.

  • launchd's PATH is only /usr/bin:/bin:/usr/sbin:/sbin. The reason daily.sh L8 does export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 first is that Japanese log output gets mangled in launchd's minimal environment. The same goes for PATH: /opt/homebrew/bin and /usr/local/bin, where Homebrew's and nvm's Python live, are invisible from launchd. Unless you explicitly set <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> in the plist's EnvironmentVariables, you get ffmpeg: command not found and everything dies.

  • A lock directory left behind after a crash wipes out every subsequent day. The locking mechanism at daily.sh L32–43 is designed to "seize the lock if the holding PID is dead per kill -0," but across a macOS reboot the PID can be reused by a different process. In that case the supposedly dead lock is misjudged as "held by a live process" and the script keeps hitting exit 0. Setting up a weekly cron or a manual rm -rf ~/dev/asmr-factory/.daily.lock.d is a useful insurance policy.

  • macOS date -j and Linux date -d are not compatible. daily.sh L101 and L66 handle both with the OR construct date -j -f "%Y-%m-%d" "$DATE" +%s 2>/dev/null || date -d "$DATE" +%s. If you don't know this pattern and port a script written for one OS to the other, date conversion throws date: illegal option -- d or date: invalid option -- 'j' and the seed calculation breaks.

  • YouTube Data API v3 has a daily quota of 10,000. videos.insert costs 1,600 per call, so 6 videos a day is the ceiling. If you keep regenerating and re-uploading with --force, or share the same API project with another project, you'll get a quota-exceeded response (HTTP 403) the next morning. youtube_upload.py has no retry logic, so in that case the timeout 1200 python3 lib/youtube_upload.py ... at daily.sh L217 fails, the else branch at L221 emits "WARN: YouTube upload failed (stock retained)," and the upload is skipped. The stock (~/Desktop/ASMR/) isn't lost so you can upload manually later, but the quota doesn't recover until the next day.

  • Forgetting the initial --auth silently skips every daily upload and is hard to notice. daily.sh L207–208 checks [ -f "$HOME/.youtube/token.json" ], and if the file doesn't exist it logs a WARN saying "YouTube not authenticated → upload skipped" and exits normally. The stock itself is still produced, so generation looks successful, but the videos never make it to YouTube. Run python3 lib/youtube_upload.py --auth once in an interactive session and complete the browser consent flow.

  • YouTube titles get silently truncated at 100 characters by a Python slice. spec["title"][:100] at youtube_upload.py L63 throws no exception. Even if you compose a long Japanese title in make_meta.py, only the first 100 characters get sent over the API. You only notice the tail is gone when you check YouTube Studio. Aim for 75 characters or fewer, or add assert len(title) <= 80 on the make_meta.py side for peace of mind.

  • Increasing --minutes leaves mix_audio.sh's 2-minute bed track short. bash "$LIB/mix_audio.sh" "$BED" 120 ... at daily.sh L164 generates a 120-second (2-minute) bed. Extension to 30 minutes is handled by looping in make_full.sh. If you switch to --minutes 60, depending on make_full.sh's internal loop implementation the 2-minute source may not extend cleanly to 60 minutes. When changing the duration, align the 120 in mix_audio.sh to $MIN*60 as well and verify.

  • sed -i '' is macOS-only; on Linux it errors with sed: 1: "...". sed -i '' "s|,OK$|,OK+uploaded|" "$ROOT/coverage.csv" 2>/dev/null || true at daily.sh L220 targets the macOS sed. Since || true silences the error, porting to Linux leaves coverage.csv un-updated while everything looks fine. When porting to Linux, rewrite it as sed -i "s|...".

  • The validation step calls from PIL import Image, so Pillow is required. daily.sh L184 verifies the thumbnail size with python3 -c "from PIL import Image;print(...)". In an environment without Pillow you get ModuleNotFoundError and a die. Check that pip3 install Pillow has been run for the Python that launchd invokes (usually the Homebrew one). I actually hit a case where I had it in my own venv during development but launchd was calling a different Python.

  • --timeout 300 (5 minutes) is too short for ComfyUI in CPU mode. Because daily.sh L53 starts it as nohup .venv/bin/python main.py --port 8188 --cpu, generating a 1024×576 image takes 3–8 minutes on an M1 CPU. It hits the comfy_gen.py --timeout 300 limit, triggers retries, and if all three attempts (att=0,1,2) fail it stops with die "image generation failed after retries". On CPU-only machines, extend it to --timeout 600 or bring ComfyUI up before launchd starts, and it stabilizes.

  • When reusing an existing image with --still, the idempotency check blocks you without --force. The idempotency check at daily.sh L86–89 exits immediately when FORCE=0 and deliverables exist for the day. Even if you try to remix with --still scene.png --theme-id X, it does nothing if that day's output already exists. Always add --force when you explicitly want to regenerate or remix.


Best practices

Principles I confirmed as "follow these and it doesn't break" over six months of unattended operation.

1. Generate the displacement maps once and cache them

if [ ! -f "$MAPDIR/map_0001.png" ] at daily.sh L123 does this. Restricting the 384-PNG generation (about 3 minutes on CPU) to the first run dropped daily runtime from 7–8 minutes to the low 4-minute range. As long as the resolution (1024×576) and loop length (16 seconds) don't change, there is zero reason to regenerate the maps.

2. Fix raindrop speed to n × span / T (n an integer) to guarantee the loop mathematically

speed = n * span / T (with n being 1 or 2) at gen_rain_glass_map.py L38–44 is the basis of the seamless loop. At t=T, the distance each drop has traveled is an integer multiple of span, so the modulo result matches t=0 exactly. The wobble is a sine wave using wobn (an integer of 1 or 2), so it also completes a cycle at t=T. Narrowing speed variation to two kinds and compensating for diversity with radius (3–26px) and amplitude differences — that trade-off is the single most important design decision.

3. Always put format=gbrp at the head of the filter graph

[0:v]format=gbrp,setsar=1[still] at render_loop.sh L30 is mandatory. The displace filter won't accept YUV-family input; pass it without conversion and the screen turns dark green or the process dies silently. Apply this rule to every filter_complex that mixes a JPEG-derived YUV still with a PNG displacement map.

4. Control effect intensity via environment variables instead of hardcoding

RAIN_OP=0.8 (daily.sh L134), GFLK, and FFLK (render_loop.sh L18–21) are all environment variables or dynamically computed values. Adjusting intensity means rewriting a single number, so there's no need for a separate script per theme.

5. Make audio fetching two-layered: timeout 90 plus a cc0 → any fallback

Freesound's preview delivery sometimes never sends the terminator, so requests.get alone hangs forever (daily.sh L148). The three layers — a 90-second timeout, CC0 preference, and license fallback — reliably prevent "a silent video with zero audio."

6. Gate output behind four die-enforced checks

30-minute duration ±3 seconds, both video and audio streams present, thumbnail 1280x720, metadata file non-empty (daily.sh L179–186). Only deliverables that pass this validation gate reach ~/Desktop/ASMR/. It mechanically prevents the worst case of "a broken video gets uploaded to YouTube."

7. Move deliverables by copying into _deliver/ and then mv (atomic)

As in daily.sh L189–197, gather the finished artifacts in _deliver/ and then move with rm -rf "$FINAL"; mv "$STAGE" "$FINAL". Even if the process dies mid-cp, no half-finished directory is left on the Desktop, and old and new deliverables never coexist even momentarily.

8. Implement idempotency as "skip if even one exists for this date"

The check via find "$DEST" -maxdepth 1 -type d -name "${DATE}_*" at daily.sh L87–88. Even with a different theme, a second video isn't generated the same day. Even with launchd firing twice at 7:00 and 14:00, no double generation occurs. Designing it so --force explicitly overrides lets one flag control both idempotency and manual regeneration.

9. Never delete the stock on upload failure. Decouple generation from upload

The else branch at daily.sh L221 only emits a WARN log — it doesn't die. The video.mp4 in ~/Desktop/ASMR/ stays, and you can upload manually later with python3 lib/youtube_upload.py --upload upload.json. The design confines the blast radius of network failures, expired tokens, and quota overruns to the upload step alone, so no generated output is ever lost.

10. Make the global flicker a superposition of 3Hz and 7Hz (coprime)

The reason GFLK at render_loop.sh L18–20 uses the coefficients 3*LOOPSEC=48 and 7*LOOPSEC=112 is that both have periods that are integer multiples of the loop length, so they don't affect the seam (both sine waves complete a cycle between t=0 and t=T=16). The amplitudes are set to the faint pulsing of an indoor bulb (0.018 and 0.010), right at the edge of what the eye consciously follows. A single frequency looks like artificial flicker, and random noise breaks the loop.

11. Unify themes around "warm light + a window" to stabilize auto-mask accuracy

Window and fire region detection in auto_masks.py loses accuracy when the composition isn't stable. The README's explicit "Themes: 7, all unified around a warm-light indoor composition with a window" is not about aesthetic consistency — it's a design constraint that makes the automation viable. When adding a theme, keeping "a window is always in frame, a warm light source exists" keeps both mask accuracy and manual-correction cost under control.

12. Cap hero-drop radius at 14–26px

r = float(rng.uniform(14, 26)) at gen_rain_glass_map.py L58. Widen this past 30px and individual raindrops stand out so much that a 30-minute loop starts to feel unnatural instead. "What works as ASMR footage is the texture of raindrops staying in the background" — they must not become the star. 26px is the upper bound I arrived at experimentally; past that, viewers started pointing it out in the comments.

13. Compose YouTube titles within 75 characters. At 100, a Python slice silently truncates

spec["title"][:100] at youtube_upload.py L63 throws no exception. When composing titles in make_meta.py, aim for 75 characters or fewer, or add assert len(title) <= 80 so you find out.

14. Extend comfy_gen.py's timeout to 600 seconds to match CPU mode

A GPU-based design is fine with 300 seconds (--timeout 300 at daily.sh L106), but on real hardware started with --cpu (L53), generating at 1024×576 can take 3–8 minutes. If you're running CPU-only, change it to --timeout 600 and design so the maximum wait including three retries (att=0,1,2) stays within 30 minutes.


Wrap-up

Boil down why this pipeline works and it converges on three design decisions.

"Don't animate what doesn't need to animate." One still image from RealVisXL is reused, and a single first-run cache of displacement maps covers every theme. The GPU is used only for ComfyUI image generation. The rest is CPU numerics and ffmpeg filter processing. That's where generation cost effectively hit zero.

Without a "mathematically correct loop" you can't mass-produce. Two periodicity constraints — speed = n * span / T (n an integer) and wobn (an integer of 1 or 2) — make the first and last frames of the 16-second loop match exactly. Without them, the seam repeated 112 times in a 30-minute video becomes a visual rupture and it stops functioning as ASMR. The math isn't there to "look pretty" — it's there so the thing doesn't break at volume.

The three principles "validate, idempotent, atomic" protect unattended operation. The four-point pre-output validation (L179–186), the idempotency check (L87–88), and the atomic move (L189–197) are the safety devices that keep a broken video from shipping without a human checking daily. launchd fires at 7:00 and four minutes later the finished product lands in ~/Desktop/ASMR/. All I do is open YouTube Studio the next morning and press publish.

Six months of running this, and the ASMR channel is still a stable pillar of monthly revenue. Zero GPU spend, CC0 audio only, entirely local. Apart from the two weekend days of initial implementation, the ongoing cost is electricity. Making real rain fall from a single still image turned out to be far simpler than I expected.


I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day walkthrough in a paid note.
📕 How you actually earn with a Claude Code autonomous setup — the system, real examples, how to start, and support


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)