{"slug": "zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image", "title": "Zero GPU Cost and 4-Minute Daily Runs — Making Real Rain Fall on a Still Image with ffmpeg displace", "summary": "A developer built a zero-GPU-cost system that generates 30-minute ASMR rain videos daily from a single still image using Python physics simulations and ffmpeg's displace filter, cutting generation time from 7-8 minutes to just over 4 minutes. The setup, part of an autonomous revenue stream that now earns ¥1.2M a month, uses periodic displacement maps to create seamless loops and automatic masking to apply effects consistently across seven themes.", "body_md": "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`\n\nfilter.\n\nFor 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**.\n\nASMR 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.\n\nThe 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.\n\nThe 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.\n\nYou can build that from a still image.\n\nI 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`\n\nfilter. No video model. Zero GPU spend. The only cost of generation is CPU time.\n\nThe other core piece is the **seamless loop**. In `~/dev/asmr-factory/daily.sh`\n\nthe setting is `LOOPSEC=16`\n\n— a 16-second loop video that `make_full.sh`\n\ntiles 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.\n\nThe README says: \"Themes: 7, all unified around a warm-light indoor composition with a window.\" Why unify?\n\nAutomatic masking (`lib/auto_masks.py`\n\n) 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.\n\nThe launchd config in `~/dev/asmr-factory/`\n\n(`com.lily.asmr-daily`\n\n) fires `daily.sh`\n\nat 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.\n\nHere's the whole pipeline.\n\n```\nComfyUI (RealVisXL @ 127.0.0.1:8188)\n    │  still.png ─ 1024×576px\n    ▼\nlib/auto_masks.py\n    │  win_mask.png  (窓領域マスク)\n    │  fire_mask.png (炎領域マスク・テーマによりスキップ)\n    ▼\nlib/gen_rain_glass_map.py      ← ★ 今回の主役\n    │  maps/daily_16s/map_0001.png\n    │  maps/daily_16s/map_0002.png\n    │  … (24fps × 16s = 384枚)\n    │  ※ 初回生成後はキャッシュ使い回し\n    ▼\nlib/render_loop.sh             ← ★ 今回の主役\n    │  loop.mp4 (16秒シームレスループ)\n    ▼\nlib/freesound_fetch.py  +  lib/mix_audio.sh\n    │  bed.wav (CC0音源 / loudnorm -20LUFS / 2分尺)\n    ▼\nlib/make_full.sh\n    │  video.mp4 (30分・ループをタイル)\n    ▼\nlib/make_thumb.py  +  lib/make_meta.py\n    │  thumbnail.png (1280×720) / youtube.md\n    ▼\nlib/youtube_upload.py\n       YouTube「非公開」アップ (公開は人間が確認して押す)\n```\n\nLet's walk through each step alongside the code in `daily.sh`\n\n.\n\n```\nSEEDBASE=$(( $(date -j -f \"%Y-%m-%d\" \"$DATE\" +%s 2>/dev/null \\\n               || date -d \"$DATE\" +%s) % 100000 ))\nok=0\nfor att in 0 1 2; do\n  SEED=$(( SEEDBASE + att*777 ))\n  log \"comfy_gen attempt $att seed=$SEED\"\n  if python3 \"$LIB/comfy_gen.py\" \\\n       --prompt \"$PROMPT\" --seed \"$SEED\" --out \"$STILL\" \\\n       --timeout 300 >>\"$LOG\" 2>&1; then\n    ok=1; break\n  fi\n  sleep $(( (att+1)*10 ))\ndone\n[ \"$ok\" -eq 1 ] || die \"image generation failed after retries\"\n```\n\n(from `daily.sh`\n\nL101–111)\n\nThe seed is based on the date's UNIX timestamp modulo 100000. Shifting it by `+777`\n\non each retry means the same date still gets a different seed on every attempt. If ComfyUI itself is down, the `ensure_comfyui()`\n\nfunction at `daily.sh`\n\nL50–60 tries to start it. If it doesn't come up after 150 attempts (about 10 minutes), the script aborts safely via `die`\n\n.\n\n```\n# ---- 3. 雨マップ(サイズ固定なのでキャッシュ流用) ----\nLOOPSEC=16\nMAPDIR=\"$ROOT/maps/daily_${LOOPSEC}s\"\nif [ ! -f \"$MAPDIR/map_0001.png\" ]; then\n  log \"generating rain map (cache)\"\n  mkdir -p \"$MAPDIR\"\n  python3 \"$LIB/gen_rain_glass_map.py\" \\\n    --w 1024 --h 576 --fps 24 --seconds \"$LOOPSEC\" \\\n    --drops 46 --out \"$MAPDIR\" --seed 7 >>\"$LOG\" 2>&1 \\\n    || die \"rain map gen failed\"\nfi\n```\n\n(from `daily.sh`\n\nL121–127)\n\nThis is the most important part of the whole series. Look at the condition `if [ ! -f \"$MAPDIR/map_0001.png\" ]`\n\n. Displacement map generation runs **exactly once, ever** — from the second run onward the entire `maps/daily_16s/`\n\ndirectory is reused.\n\nWhat the parameters mean:\n\n`--fps 24 --seconds 16`\n\n`--drops 46`\n\n`--seed 7`\n\n`--w 1024 --h 576`\n\n`displace`\n\nmisalign)I'll dig into what `gen_rain_glass_map.py`\n\nactually 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.\n\n```\n# ---- 4. ループ動画(モーション) ----\nLOOP=\"$WORK/loop.mp4\"\nWIN_MASK_ENV=\"\"; FIRE_MASK_ENV=\"\"\n[ \"$HAS_RAIN\" = \"True\" ] && WIN_MASK_ENV=\"$WORK/win_mask.png\"\n[ \"$HAS_FIRE\" = \"True\" ] && FIRE_MASK_ENV=\"$WORK/fire_mask.png\"\nWIN_MASK=\"$WIN_MASK_ENV\" FIRE_MASK=\"$FIRE_MASK_ENV\" RAIN_OP=0.8 \\\n  bash \"$LIB/render_loop.sh\" \"$STILL\" \"$MAPDIR\" \"$LOOPSEC\" \"$LOOP\" \\\n    >>\"$LOG\" 2>&1 || die \"render_loop failed\"\n```\n\n(from `daily.sh`\n\nL129–135)\n\n`RAIN_OP=0.8`\n\nis the opacity of the rain effect. At `1.0`\n\nthe displacement gets so strong the glass looks warped; at `0.6`\n\nthe rain is too faint. Experiments landed on 0.8 as the most natural. `render_loop.sh`\n\nreceives this as an environment variable and expands it into the parameters of ffmpeg's `displace`\n\nfilter.\n\n`HAS_RAIN`\n\nand `HAS_FIRE`\n\ncome from the theme definitions in `themes.json`\n\n. The \"fireplace study\" theme is `HAS_FIRE=True`\n\nand `HAS_RAIN=False`\n\n; \"rainy café\" is `HAS_RAIN=True`\n\nand `HAS_FIRE=False`\n\n; \"rainy fireplace\" is `True`\n\nfor both. Processing branches on whether the mask files exist, so no unnecessary filters get inserted.\n\n```\nfor lic in cc0 any; do\n  if timeout 90 python3 \"$LIB/freesound_fetch.py\" \\\n       --query \"$Q\" --minlen 25 --license \"$lic\" \\\n       --out \"$SRC\" >\"$WORK/fs_${i}.json\" 2>>\"$LOG\"; then\n    fetched=1; break\n  fi\ndone\n```\n\n(from `daily.sh`\n\nL146–152)\n\nAudio comes from the Freesound API, preferring the CC0 license (falling back in the order `cc0`\n\n→ `any`\n\n). The `timeout 90`\n\nis applied manually because Freesound's preview download has no timeout of its own. The multiple sources fetched get mixed and normalized to loudnorm `-20LUFS`\n\nin `mix_audio.sh`\n\n. Without unified LUFS the volume swings wildly from video to video, so this step can't be skipped.\n\n`make_full.sh`\n\ntakes the loop video and the audio and tiles them out to 30 minutes (default `MIN=30`\n\n). A 16-second loop × roughly 112.5 repetitions = 30 minutes. ffmpeg's `-stream_loop`\n\noption repeats the video, and the audio is explicitly cut to `MIN`\n\nminutes rather than relying on `-shortest`\n\n.\n\n```\n# ---- 9. 検証 ----\nDUR=$(ffprobe -v error -show_entries format=duration \\\n       -of csv=p=0 \"$VIDEO\" 2>/dev/null | cut -d. -f1)\nWANT=$((MIN*60))\n[ -n \"$DUR\" ] && [ \"$DUR\" -ge $((WANT-3)) ] && \\\n  [ \"$DUR\" -le $((WANT+3)) ] || die \"duration check failed ($DUR != $WANT)\"\nSTREAMS=$(ffprobe -v error -show_entries stream=codec_type \\\n           -of csv=p=0 \"$VIDEO\" 2>/dev/null | sort | tr '\\n' ',')\necho \"$STREAMS\" | grep -q \"audio\" && echo \"$STREAMS\" | grep -q \"video\" \\\n  || die \"missing stream ($STREAMS)\"\nTW=$(python3 -c \\\n  \"from PIL import Image;print('x'.join(map(str,Image.open('$THUMB').size)))\")\n[ \"$TW\" = \"1280x720\" ] || die \"thumb size $TW != 1280x720\"\n[ -s \"$META\" ] || die \"meta empty\"\n```\n\n(from `daily.sh`\n\nL179–186)\n\nFour 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`\n\n. ④ The metadata file isn't empty. If even one fails, `die`\n\nkills 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.\n\n```\n# ---- 10. atomic stock ----\nSTAGE=\"$WORK/_deliver\"; mkdir -p \"$STAGE\"\ncp \"$VIDEO\" \"$STAGE/video.mp4\"\ncp \"$THUMB\" \"$STAGE/thumbnail.png\"\ncp \"$META\"  \"$STAGE/youtube.md\"\ncp \"$STILL\" \"$STAGE/scene.png\"\nmkdir -p \"$DEST\"\nrm -rf \"$FINAL\"\nmv \"$STAGE\" \"$FINAL\"\n```\n\n(from `daily.sh`\n\nL189–197)\n\nDeliverables are gathered into `_deliver/`\n\ninside the working directory and then moved to the final destination with `mv`\n\n(atomic). Even if the process dies mid-copy, no half-finished directory is left on the Desktop.\n\nIdempotency is guaranteed by the check at `L86–88`\n\n.\n\n```\nEXIST=$(find \"$DEST\" -maxdepth 1 -type d \\\n         -name \"${DATE}_*\" 2>/dev/null | head -1)\nif [ -n \"$EXIST\" ]; then\n  log \"already stocked for $DATE ($EXIST); skip (idempotent)\"\n  exit 0\nfi\n```\n\nIf 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.\n\n```\nmedia = MediaFileUpload(\n    spec[\"video\"],\n    chunksize=8 * 1024 * 1024,   # 8MBチャンク\n    resumable=True,\n    mimetype=\"video/mp4\"\n)\nreq = yt.videos().insert(\n    part=\"snippet,status\", body=body, media_body=media\n)\nresp = None\nwhile resp is None:\n    status, resp = req.next_chunk()\n    if status:\n        print(f\"  upload {int(status.progress()*100)}%\",\n              file=sys.stderr)\n```\n\n(from `lib/youtube_upload.py`\n\nL73–80)\n\nBecause it uses resumable upload, a dropped connection mid-transfer can be resumed. `chunksize=8 * 1024 * 1024`\n\n(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`\n\n) and updates the `coverage.csv`\n\nrecord to `OK+uploaded`\n\n.\n\nWhat matters is that a failed upload never deletes the stock (the deliverables in `~/Desktop/ASMR/`\n\n) — see `daily.sh`\n\nL206–226. Expired token, network down, whatever the reason, the local `video.mp4`\n\nstays. You can just run `python3 lib/youtube_upload.py --upload upload.json`\n\nmanually later.\n\nNext time I'll dig into the physics simulation itself in `lib/gen_rain_glass_map.py`\n\n(droplet spawning, gravity, the window-glass surface-tension model, and the mathematical design that ties 384 frames into a cycle), plus the ffmpeg `displace`\n\nfilter syntax in `lib/render_loop.sh`\n\n.\n\nThe header comment in `lib/gen_rain_glass_map.py`\n\nspells out the structure of the output PNG.\n\n```\nR = x-displacement  (128 = neutral, refraction toward droplet center)\nG = y-displacement  (128 = neutral)\nB = specular highlight (0 = none, bright = glint on the droplet)\n```\n\nA 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.\n\nHere's where `render_loop.sh`\n\nunpacks it.\n\n```\n[1:v]setsar=1,split=3[m1][m2][m3];\n[m1]extractplanes=r[xm];\n[m2]extractplanes=g[ym];\n[m3]extractplanes=b,format=gbrp[spec];\n[todisp][xm][ym]displace=edge=smear,format=gbrp[disp];\n[disp][spec]blend=all_mode=screen:all_opacity=0.85,format=gbrp[raineff];\n```\n\n(from `lib/render_loop.sh`\n\nL36–41)\n\n`split=3`\n\nbranches the same frame into three streams, and `extractplanes=r/g/b`\n\npulls each channel out as grayscale. R (xm) and G (ym) become the X and Y displacement sources for ffmpeg's `displace`\n\nfilter, and B (spec) is additively composited on top with `blend=all_mode=screen`\n\nto become the white highlight of light.\n\nNotice that `format=gbrp`\n\nshows up everywhere. ffmpeg's `displace`\n\nfilter 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`\n\nat runtime and everything stops (more on this below).\n\nMore 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`\n\nL38–44.\n\n```\nfor _ in range(args.drops):\n    n = int(rng.integers(1, 3))        # full wraps over T -> loop\n    r = float(rng.uniform(3, 9))\n    drops.append(dict(\n        ...\n        speed=n * span / T,\n        ...\n    ))\n```\n\n(from `lib/gen_rain_glass_map.py`\n\nL37–44)\n\n`span = H + 2 * args.margin`\n\n(576 + 80 = 656 pixels) is \"the travel distance of one full cycle\" — the screen height plus top and bottom margins. `n`\n\nis an integer, 1 or 2. Since `speed = n * span / T`\n\n, the distance each droplet has traveled after 16 seconds is `speed * T = n * span`\n\n.\n\nLook at the per-frame position calculation.\n\n```\ncy = (d[\"y0\"] + d[\"speed\"] * t) % span - args.margin\n```\n\n(from `lib/gen_rain_glass_map.py`\n\nL71)\n\nAt `t = T`\n\n, `d[\"speed\"] * T = n * span`\n\n, so the result of the modulo equals `d[\"y0\"] % span`\n\n. In other words, **the positions at t=0 and t=T match exactly**. That's the mathematical basis of the seamless loop.\n\n\"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.\n\nRaindrops running down a window don't fall in straight lines. Uneven surface tension makes them meander left and right. The `wobamp`\n\nand `wobn`\n\nparameters reproduce that.\n\n```\ncx = d[\"x0\"] + d[\"wobamp\"] * math.sin(\n    2 * math.pi * d[\"wobn\"] * t / T + d[\"wobph\"]\n)\n```\n\n(from `lib/gen_rain_glass_map.py`\n\nL72)\n\nAt `t = 0`\n\nthe phase is `d[\"wobph\"]`\n\n; at `t = T`\n\nit's `2 * pi * d[\"wobn\"] * 1 + d[\"wobph\"]`\n\n. Since `wobn`\n\nis an integer of 1 or 2, `2 * pi * wobn`\n\nis either `2π`\n\nor `4π`\n\n. The period of sine is `2π`\n\n, so **the X coordinates at t=0 and t=T always match**. The initial phase\n\n`wobph`\n\nis randomized over `[0, 2π)`\n\nso each drop appears to meander differently.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\n```\nn_hero = args.hero if args.hero >= 0 else max(3, args.drops // 8)\nfor _ in range(n_hero):\n    r = float(rng.uniform(14, 26))      # fat hero droplet\n    drops.append(dict(\n        ...\n        r=r, speed=1 * span / T,        # n=1: one slow descent per loop\n        strength=float(rng.uniform(1.3, 1.8)),\n        trail=float(rng.uniform(0.8, 1.0)), glint=1.0,\n    ))\n```\n\n(from `lib/gen_rain_glass_map.py`\n\nL51–61)\n\nAgainst a `3–9px`\n\nradius for normal drops, hero drops are `14–26px`\n\n. Their `strength`\n\nis `1.3–1.8`\n\nversus `0.7–1.1`\n\n, so the lens effect is much stronger. With `--drops 46`\n\nthere are 46 normal drops plus `max(3, 46 // 8) = 5`\n\nhero drops, for 51 drops coexisting on screen.\n\nA feature unique to hero drops is the `trail`\n\n.\n\n```\nif d[\"trail\"] > 0:\n    tpad_x = max(0, int(cx - 2)); tpad_x1 = min(W, int(cx + 2))\n    ty0 = max(0, int(cy - r * 6)); ty1 = max(0, int(cy))\n    if tpad_x1 > tpad_x and ty1 > ty0:\n        tsx = xx[ty0:ty1, tpad_x:tpad_x1] - cx\n        tdist = np.abs(tsx)\n        tfall = np.clip(1.0 - tdist / 2.0, 0, 1)\n        vfade = np.clip((yy[ty0:ty1, tpad_x:tpad_x1] - (cy - r * 6)) / (r * 6), 0, 1)\n        dx[ty0:ty1, tpad_x:tpad_x1] += -(np.sign(tsx)) * tfall * vfade * (args.disp * 0.25) * d[\"trail\"]\n```\n\n(from `lib/gen_rain_glass_map.py`\n\nL95–103)\n\nIt sets a thin column 4px wide (cx±2) and `r*6`\n\ntall directly above the droplet, and applies a faint horizontal displacement (`disp * 0.25`\n\n) to it. This mimics the state where water has passed through, leaving the glass surface slightly wet and a subtle refraction behind. `vfade`\n\nis a gradient where displacement is largest near the droplet and approaches zero with distance.\n\nA distinctive part of `render_loop.sh`\n\nis that the filter graph string is assembled dynamically based on whether the `WIN_MASK`\n\nand `FIRE_MASK`\n\nenvironment variables are set.\n\n```\nFC=\"[0:v]format=gbrp,setsar=1[still];\"\nCUR=\"still\"\n\nif [ -n \"$WIN_IN\" ]; then\n  FC+=\"[1:v]setsar=1,split=3[m1][m2][m3]; ...\"\n  CUR=\"rained\"\nfi\n\nif [ -n \"$FIRE_IN\" ]; then\n  FC+=\"[${CUR}]split=2[fb][ff]; ...\"\n  CUR=\"lit\"\nfi\n\nFC+=\"[${CUR}]geq=r='r(X,Y)*${GFLK}':... ,format=yuv420p[vout]\"\n```\n\n(from `lib/render_loop.sh`\n\nL30–54)\n\nThe `CUR`\n\nvariable tracks the pipeline's \"current output label.\" Insert the rain effect and `CUR`\n\nupdates from `still → rained`\n\n, so the flame flicker that follows takes `rained`\n\nas its input. Skip both and you end up applying only the global flicker to `still`\n\n.\n\nLook at the global flicker expression.\n\n```\nC1=$(awk \"BEGIN{printf \\\"%d\\\", 3*${LOOPSEC}}\")   # = 48\nC2=$(awk \"BEGIN{printf \\\"%d\\\", 7*${LOOPSEC}}\")   # = 112\nGFLK=\"(0.975+0.018*sin(2*PI*${C1}*T/${LOOPSEC})+0.010*sin(2*PI*${C2}*T/${LOOPSEC}))\"\n```\n\n(from `lib/render_loop.sh`\n\nL18–20)\n\n`T`\n\nis ffmpeg's built-in variable for frame time. Using the coefficients `C1 = 3 * 16 = 48`\n\nand `C2 = 7 * 16 = 112`\n\n, brightness is modulated by the sum of 3Hz and 7Hz sine waves. Both sine waves complete a full cycle between `t = 0`\n\nand `t = LOOPSEC = 16`\n\n, so the flicker connects seamlessly too. The amplitudes `0.018`\n\nand `0.010`\n\nrepresent the extremely faint pulsing of an indoor bulb — an intensity chosen experimentally to sit right at the edge of what the eye consciously follows.\n\nThe flame flicker uses the same frequencies with larger amplitudes and a phase offset of `1.7`\n\nto give the modulation an organic mismatch.\n\n```\nFFLK=\"(0.78+0.15*sin(2*PI*${C1}*T/${LOOPSEC})+0.07*sin(2*PI*${C2}*T/${LOOPSEC}+1.7))\"\n```\n\n(from `lib/render_loop.sh`\n\nL21)\n\nThe base value `0.78`\n\nis 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.\n\n`format=gbrp`\n\nproduced nothing on screen\nThe first `filter_complex`\n\nI wrote was simple.\n\n```\n[0:v][1:v][2:v]displace=edge=smear[out]\n```\n\nIt produced video output, but the screen was filled with a flat dark green. Nothing appeared in the log. Raising it to `-loglevel info`\n\nrevealed `incompatible pixel formats in filter chain`\n\n.\n\nThe cause is that `displace`\n\ncan'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.\n\nThe fix is to apply `format=gbrp`\n\nright after loading the displacement map, and convert the still side the same way before passing anything to `displace`\n\n. That's exactly what `[0:v]format=gbrp,setsar=1[still]`\n\nat `render_loop.sh`\n\nL30 and the R/G/B expansion following `[1:v]setsar=1`\n\nat L36 are for. Without `format=gbrp`\n\nthe still's colors skew green, and since it fails silently, the cause is extremely hard to find.\n\nIn the first implementation I made `speed`\n\na random float, something like `rng.uniform(20, 80)`\n\npixels per second, on the reasoning that \"moving independently must look more realistic.\"\n\nChecking 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.\n\nThe cause is what I explained above: the drop positions at `t=0`\n\nand `t=T`\n\ndon't match. Adding the constraint `speed = n * span / T`\n\n— \"an integer number of full spans of travel\" — solved it the moment it went in.\n\nAs a side effect, drop speed variation is limited to two kinds, `n=1`\n\nor `n=2`\n\n, but the radius spread (3–26px) and the differences in wobble amplitude more than compensate for realism.\n\nI wrote `auto_masks.py`\n\non the assumption that the window mask is \"white for the window area, black elsewhere.\" But checking the spec of ffmpeg's `maskedmerge`\n\nfilter, the correct behavior is that **the whiter the third input, the more the second input (the effect side) is used**.\n\nIn 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.\"\n\nI checked the mask generation logic in `auto_masks.py`\n\nand 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`\n\n.\n\n```\n[base][raineff][winmask]maskedmerge\n```\n\n`maskedmerge`\n\ntakes its inputs in the order `[base video][effect video][mask]`\n\n. I had originally passed `[raineff][base][winmask]`\n\n, 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.\n\nThe initial audio-fetching implementation had no `timeout`\n\n, 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`\n\nsets no timeout by default, so the download continues forever.\n\nThe `timeout 90`\n\nat `daily.sh`\n\nL148 exists to prevent this.\n\n```\nif timeout 90 python3 \"$LIB/freesound_fetch.py\" \\\n     --query \"$Q\" --minlen 25 --license \"$lic\" \\\n     --out \"$SRC\" >\"$WORK/fs_${i}.json\" 2>>\"$LOG\"; then\n```\n\n(from `daily.sh`\n\nL148)\n\nIt kills the whole process at 90 seconds. A failed audio slot is recorded with `log \"WARN: sound fetch failed: $Q\"`\n\n, and processing continues as long as at least one other slot succeeded (the `[ \"$idx\" -ge 1 ]`\n\ncheck 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.\n\nThe 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?\"\n\nGenerating 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.\n\nThe `if [ ! -f \"$MAPDIR/map_0001.png\" ]`\n\nat `daily.sh`\n\nL123 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`\n\nruns in the low 4-minute range on average.\n\nNext time I'll cover the segmentation implementation in `lib/auto_masks.py`\n\n, the audio-drift countermeasures when `lib/make_full.sh`\n\ntiles the 16-second loop out to 30 minutes, and the actual channel revenue numbers.\n\nOn top of the five detailed above (`format=gbrp`\n\n, loop-seam teleporting, `maskedmerge`\n\nargument 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.\n\n**launchd's PATH is only /usr/bin:/bin:/usr/sbin:/sbin.** The reason\n\n`daily.sh`\n\nL8 does `export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8`\n\nfirst is that Japanese log output gets mangled in launchd's minimal environment. The same goes for PATH: `/opt/homebrew/bin`\n\nand `/usr/local/bin`\n\n, 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>`\n\nin the plist's `EnvironmentVariables`\n\n, you get `ffmpeg: command not found`\n\nand everything dies.**A lock directory left behind after a crash wipes out every subsequent day.** The locking mechanism at `daily.sh`\n\nL32–43 is designed to \"seize the lock if the holding PID is dead per `kill -0`\n\n,\" 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`\n\n. Setting up a weekly cron or a manual `rm -rf ~/dev/asmr-factory/.daily.lock.d`\n\nis a useful insurance policy.\n\n**macOS date -j and Linux date -d are not compatible.**\n\n`daily.sh`\n\nL101 and L66 handle both with the OR construct `date -j -f \"%Y-%m-%d\" \"$DATE\" +%s 2>/dev/null || date -d \"$DATE\" +%s`\n\n. 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`\n\nor `date: invalid option -- 'j'`\n\nand 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\n\n`--force`\n\n, or share the same API project with another project, you'll get a quota-exceeded response (HTTP 403) the next morning. `youtube_upload.py`\n\nhas no retry logic, so in that case the `timeout 1200 python3 lib/youtube_upload.py ...`\n\nat `daily.sh`\n\nL217 fails, the `else`\n\nbranch at L221 emits \"WARN: YouTube upload failed (stock retained),\" and the upload is skipped. The stock (`~/Desktop/ASMR/`\n\n) 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.**\n\n`daily.sh`\n\nL207–208 checks `[ -f \"$HOME/.youtube/token.json\" ]`\n\n, 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`\n\nonce 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]`\n\nat `youtube_upload.py`\n\nL63 throws no exception. Even if you compose a long Japanese title in `make_meta.py`\n\n, 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`\n\non the `make_meta.py`\n\nside for peace of mind.\n\n**Increasing --minutes leaves mix_audio.sh's 2-minute bed track short.**\n\n`bash \"$LIB/mix_audio.sh\" \"$BED\" 120 ...`\n\nat `daily.sh`\n\nL164 generates a 120-second (2-minute) bed. Extension to 30 minutes is handled by looping in `make_full.sh`\n\n. If you switch to `--minutes 60`\n\n, depending on `make_full.sh`\n\n's internal loop implementation the 2-minute source may not extend cleanly to 60 minutes. When changing the duration, align the `120`\n\nin `mix_audio.sh`\n\nto `$MIN*60`\n\nas well and verify.`sed -i ''`\n\nis macOS-only; on Linux it errors with `sed: 1: \"...\"`\n\n.`sed -i '' \"s|,OK$|,OK+uploaded|\" \"$ROOT/coverage.csv\" 2>/dev/null || true`\n\nat `daily.sh`\n\nL220 targets the macOS `sed`\n\n. Since `|| true`\n\nsilences the error, porting to Linux leaves coverage.csv un-updated while everything looks fine. When porting to Linux, rewrite it as `sed -i \"s|...\"`\n\n.\n\n**The validation step calls from PIL import Image, so Pillow is required.**\n\n`daily.sh`\n\nL184 verifies the thumbnail size with `python3 -c \"from PIL import Image;print(...)\"`\n\n. In an environment without Pillow you get `ModuleNotFoundError`\n\nand a `die`\n\n. Check that `pip3 install Pillow`\n\nhas 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\n\n`daily.sh`\n\nL53 starts it as `nohup .venv/bin/python main.py --port 8188 --cpu`\n\n, generating a 1024×576 image takes 3–8 minutes on an M1 CPU. It hits the `comfy_gen.py --timeout 300`\n\nlimit, triggers retries, and if all three attempts (`att=0,1,2`\n\n) fail it stops with `die \"image generation failed after retries\"`\n\n. On CPU-only machines, extend it to `--timeout 600`\n\nor 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\n\n`daily.sh`\n\nL86–89 exits immediately when `FORCE=0`\n\nand deliverables exist for the day. Even if you try to remix with `--still scene.png --theme-id X`\n\n, it does nothing if that day's output already exists. Always add `--force`\n\nwhen you explicitly want to regenerate or remix.Principles I confirmed as \"follow these and it doesn't break\" over six months of unattended operation.\n\n**1. Generate the displacement maps once and cache them**\n\n`if [ ! -f \"$MAPDIR/map_0001.png\" ]`\n\nat `daily.sh`\n\nL123 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.\n\n**2. Fix raindrop speed to n × span / T (n an integer) to guarantee the loop mathematically**\n\n`speed = n * span / T`\n\n(with `n`\n\nbeing 1 or 2) at `gen_rain_glass_map.py`\n\nL38–44 is the basis of the seamless loop. At `t=T`\n\n, the distance each drop has traveled is an integer multiple of `span`\n\n, so the modulo result matches `t=0`\n\nexactly. The wobble is a sine wave using `wobn`\n\n(an integer of 1 or 2), so it also completes a cycle at `t=T`\n\n. 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.\n\n**3. Always put format=gbrp at the head of the filter graph**\n\n`[0:v]format=gbrp,setsar=1[still]`\n\nat `render_loop.sh`\n\nL30 is mandatory. The `displace`\n\nfilter 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`\n\nthat mixes a JPEG-derived YUV still with a PNG displacement map.\n\n**4. Control effect intensity via environment variables instead of hardcoding**\n\n`RAIN_OP=0.8`\n\n(`daily.sh`\n\nL134), `GFLK`\n\n, and `FFLK`\n\n(`render_loop.sh`\n\nL18–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.\n\n**5. Make audio fetching two-layered: timeout 90 plus a cc0 → any fallback**\n\nFreesound's preview delivery sometimes never sends the terminator, so `requests.get`\n\nalone hangs forever (`daily.sh`\n\nL148). The three layers — a 90-second timeout, CC0 preference, and license fallback — reliably prevent \"a silent video with zero audio.\"\n\n**6. Gate output behind four die-enforced checks**\n\n30-minute duration ±3 seconds, both video and audio streams present, thumbnail `1280x720`\n\n, metadata file non-empty (`daily.sh`\n\nL179–186). Only deliverables that pass this validation gate reach `~/Desktop/ASMR/`\n\n. It mechanically prevents the worst case of \"a broken video gets uploaded to YouTube.\"\n\n**7. Move deliverables by copying into _deliver/ and then mv (atomic)**\n\nAs in `daily.sh`\n\nL189–197, gather the finished artifacts in `_deliver/`\n\nand then move with `rm -rf \"$FINAL\"; mv \"$STAGE\" \"$FINAL\"`\n\n. Even if the process dies mid-`cp`\n\n, no half-finished directory is left on the Desktop, and old and new deliverables never coexist even momentarily.\n\n**8. Implement idempotency as \"skip if even one exists for this date\"**\n\nThe check via `find \"$DEST\" -maxdepth 1 -type d -name \"${DATE}_*\"`\n\nat `daily.sh`\n\nL87–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`\n\nexplicitly overrides lets one flag control both idempotency and manual regeneration.\n\n**9. Never delete the stock on upload failure. Decouple generation from upload**\n\nThe `else`\n\nbranch at `daily.sh`\n\nL221 only emits a WARN log — it doesn't `die`\n\n. The `video.mp4`\n\nin `~/Desktop/ASMR/`\n\nstays, and you can upload manually later with `python3 lib/youtube_upload.py --upload upload.json`\n\n. 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.\n\n**10. Make the global flicker a superposition of 3Hz and 7Hz (coprime)**\n\nThe reason `GFLK`\n\nat `render_loop.sh`\n\nL18–20 uses the coefficients `3*LOOPSEC=48`\n\nand `7*LOOPSEC=112`\n\nis 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`\n\nand `t=T=16`\n\n). 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.\n\n**11. Unify themes around \"warm light + a window\" to stabilize auto-mask accuracy**\n\nWindow and fire region detection in `auto_masks.py`\n\nloses 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.\n\n**12. Cap hero-drop radius at 14–26px**\n\n`r = float(rng.uniform(14, 26))`\n\nat `gen_rain_glass_map.py`\n\nL58. 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.\n\n**13. Compose YouTube titles within 75 characters. At 100, a Python slice silently truncates**\n\n`spec[\"title\"][:100]`\n\nat `youtube_upload.py`\n\nL63 throws no exception. When composing titles in `make_meta.py`\n\n, aim for 75 characters or fewer, or add `assert len(title) <= 80`\n\nso you find out.\n\n**14. Extend comfy_gen.py's timeout to 600 seconds to match CPU mode**\n\nA GPU-based design is fine with 300 seconds (`--timeout 300`\n\nat `daily.sh`\n\nL106), but on real hardware started with `--cpu`\n\n(L53), generating at 1024×576 can take 3–8 minutes. If you're running CPU-only, change it to `--timeout 600`\n\nand design so the maximum wait including three retries (att=0,1,2) stays within 30 minutes.\n\nBoil down why this pipeline works and it converges on three design decisions.\n\n**\"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.\n\n**Without a \"mathematically correct loop\" you can't mass-produce.** Two periodicity constraints — `speed = n * span / T`\n\n(n an integer) and `wobn`\n\n(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.\n\n**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/`\n\n. All I do is open YouTube Studio the next morning and press publish.\n\nSix 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.\n\nI'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.\n\n📕 [How you actually earn with a Claude Code autonomous setup — the system, real examples, how to start, and support](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image", "canonical_source": "https://dev.to/bokuwalily/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image-with-ffmpeg-displace-b5b", "published_at": "2026-08-15 11:00:47+00:00", "updated_at": "2026-08-15 11:12:00.341873+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "developer-tools", "computer-vision"], "entities": ["ffmpeg", "RealVisXL", "ComfyUI", "Claude Code", "YouTube", "Sora", "Runway Gen-3"], "alternates": {"html": "https://wpnews.pro/news/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image", "markdown": "https://wpnews.pro/news/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image.md", "text": "https://wpnews.pro/news/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image.txt", "jsonld": "https://wpnews.pro/news/zero-gpu-cost-and-4-minute-daily-runs-making-real-rain-fall-on-a-still-image.jsonld"}}