Zero GPU Cost and 4-Minute Daily Runs — Making Real Rain Fall on a Still Image with ffmpeg displace 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. 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 . 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. 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. 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. 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「非公開」アップ 公開は人間が確認して押す Let's walk through each step alongside the code in daily.sh . 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" 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 . ---- 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 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 --drops 46 --seed 7 --w 1024 --h 576 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. ---- 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" 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. 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 from daily.sh L146–152 Audio comes from the Freesound API, preferring the CC0 license falling back in the order cc0 → any . 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 . ---- 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" 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. ---- 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" 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 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. 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 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 . 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 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 ; 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 . 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, ... 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 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. 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" 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 2π or 4π . The period of sine is 2π , 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.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, 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" 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. 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 " 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} " 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 " 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. format=gbrp produced nothing on screen The first filter complex I wrote was simple. 0:v 1:v 2:v displace=edge=smear out 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. 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. 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 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. 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 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. 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. 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