Merge pull request #60 from outis1one/clDemoe/digital-zoom-moon-capture-DprtQ

Switch monthly phase close-ups to NASA SVS Dial-a-Moon at fullscreen
This commit is contained in:
Outis
2026-05-01 09:39:13 -04:00
committed by GitHub
6 changed files with 394 additions and 79 deletions
+14 -4
View File
@@ -6,7 +6,7 @@ Automated sky / timelapse camera scripts that produce:
- **Four Seasons timelapse** — daily clips sized to each Vivaldi movement's music duration, assembled automatically into per-movement montages (with music + attribution overlay) and a full-year video
- **Full-day timelapse** — a fixed-fps timelapse of every image captured that day, kept for a configurable retention window
- **Nightly moon-track timelapse** — every east night frame where the moon is visible is cropped around the moon and stitched into a short mp4 (the moon roughly held still while clouds and stars drift past)
- **Monthly moon-phase close-ups** — one composite per phase (full moon, first quarter, third quarter), framed as if east took it through a 65× telephoto. The sky/halo/parallactic-angle/timing are real-from-east; the lunar surface texture is borrowed from a cached high-res reference (no software can recover detail your camera didn't capture). Posted to Mattermost.
- **Monthly moon-phase close-ups** — one full-screen image per phase (full moon, first quarter, third quarter). East acts as the witness, confirming the moon was visible in your sky during the collection window and supplying the timestamp. The image itself is a NASA SVS Dial-a-Moon render for that exact UTC hour — accurate phase, real libration, correct crater shadows — sized to fill the frame (~92% of height) on a black background, the way a long-telephoto shot looks. Posted to Mattermost.
---
@@ -303,12 +303,22 @@ python3 moon_phase.py info 2026-04-29T21:07:00Z
|---|---|---|
| Sunrise video | `SCHEDULE_SUNRISE` (default 03:00 local) | A few minutes after sunrise + `SUNRISE_POST_MIN` |
| Moon-track timelapse | `SCHEDULE_MOON_TRACK` (default 02:30 local) | ~5 min after the timer, covers the previous night |
| 🌕 Full Moon composite | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) |
| 🌓 First Quarter composite | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below |
| 🌗 Third Quarter composite | same timer | 2 days after exact third quarter |
| 🌕 Full Moon close-up | `SCHEDULE_MOON_PHASE` (default 09:30 local) | 3 days after exact full moon (configurable: `MOON_FULL_POST_DELAY_DAYS`) |
| 🌓 First Quarter close-up | same timer | 2 days after exact first quarter (configurable: `MOON_QUARTER_POST_DELAY_DAYS`) — best-effort, see geometry note below |
| 🌗 Third Quarter close-up | same timer | 2 days after exact third quarter |
The moon-phase timer fires every day; the script no-ops on days that aren't a post-day for any phase, so you'll see exactly three posts per lunar cycle in Mattermost (sometimes only two if first quarter detection fails — see geometry note in `sky-cam.conf`).
### How the moon close-ups work
The phase posts are **honest composites**: east is the witness, NASA is the photographer.
- East scans the collection window (D-1 .. D+2 for full, D-1 .. D+1 for quarters), runs moon detection on each frame, and picks the moment closest to the exact phase that meets quality / altitude / illumination thresholds.
- That picked moment becomes the timestamp we send to NASA's [SVS Dial-a-Moon](https://svs.gsfc.nasa.gov/api/dialamoon/) — a free public service that returns a real-physics moon render for any UTC hour, complete with correct phase, libration, and crater shadows.
- The render is sized to fill ~92% of a 1920×1080 frame on a black background and posted to Mattermost with a caption listing the witnessed-by-east moment and the NASA attribution.
- One API call per phase event (~36/year), cached forever locally.
- If you'd rather post the NASA render every cycle regardless of weather over east, set `MOON_REQUIRE_EAST_VERIFY=false` in `sky-cam.conf`.
**Check capture status**:
```bash
systemctl --user status sky-cam-capture-east.service
+5 -19
View File
@@ -243,25 +243,11 @@ if [ "${MOON_TRACK_ENABLED:-true}" = "true" ] || [ "${MOON_FULL_ENABLED:-true}"
fi
fi
# Lunar reference texture for moon_phase_monthly.py. Default URL is the
# Wikipedia "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500.
# Override MOON_REFERENCE_URL in .env to use a different source — any
# high-res photo of a full moon on a black background works.
ref_dir="${MOON_REFERENCE_DIR:-$HERE/moon-ref}"
ref_path="${MOON_REFERENCE_PATH:-$ref_dir/full-moon.jpg}"
ref_url="${MOON_REFERENCE_URL:-https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg}"
if [ ! -f "$ref_path" ]; then
mkdir -p "$(dirname "$ref_path")"
echo "Downloading lunar reference texture..."
if curl -fsSL -o "$ref_path" "$ref_url"; then
echo " ok → $ref_path"
else
echo " WARNING: lunar reference download failed"
echo " Drop a high-res full-moon JPEG at $ref_path manually,"
echo " or set MOON_REFERENCE_URL in .env and re-run install.sh."
rm -f "$ref_path"
fi
fi
# NASA SVS Dial-a-Moon images are fetched on demand by moon_dialamoon.py
# the first time each phase event is processed (~once per phase per month,
# ~36 calls/year), cached forever in moon-ref/dialamoon/. No bulk
# download needed at install time.
mkdir -p "${MOON_DIALAMOON_CACHE_DIR:-$HERE/moon-ref/dialamoon}"
fi
# Nightly moon-track job — runs on the SUNRISE_CAM only.
+41
View File
@@ -209,6 +209,47 @@ def composite_full_moon(
return out_path
def render_phase_closeup(
nasa_render_path: str,
out_path: str,
output_size: tuple[int, int] = (1920, 1080),
moon_height_pct: float = 0.92,
caption: str | None = None,
background: tuple[int, int, int] = (0, 0, 0),
):
"""Full-screen close-up rendering using a NASA SVS Dial-a-Moon image.
The dial-a-moon render already has the correct phase, libration and
crater shadows for the requested timestamp, so we simply size it to fill
the output frame on a black background and add a caption. No east
compositing — the moon dominates the frame the way a 65× telephoto shot
would.
"""
bg = Image.new('RGB', output_size, background)
moon = Image.open(nasa_render_path).convert('RGB')
moon = _square_crop_to_disk(moon)
target = int(round(output_size[1] * moon_height_pct))
target += target % 2
moon_resized = moon.resize((target, target), LANCZOS)
feather = max(3, target // 240)
mask = _disk_mask(target, feather_px=feather)
px = (output_size[0] - target) // 2
py = (output_size[1] - target) // 2
bg.paste(moon_resized, (px, py), mask)
if caption:
draw = ImageDraw.Draw(bg)
draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
draw.text((22, output_size[1] - 46), caption, fill=(220, 220, 220))
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
bg.save(out_path, quality=92)
return out_path
def _cli():
p = argparse.ArgumentParser()
p.add_argument('source')
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""moon_dialamoon.py — fetch + cache NASA SVS Dial-a-Moon renders.
Dial-a-Moon publishes hourly pre-rendered moon images for the current year
that include:
- exact phase (terminator with real crater shadows)
- real libration (the moon "wobbles" up to ~7° at the limb)
- exact illumination fraction
- apparent diameter
For our purpose we want the image as it would appear to a ground observer at
the requested UTC moment, downloaded once per phase event and cached forever
under $MOON_DIALAMOON_CACHE_DIR.
API: https://svs.gsfc.nasa.gov/api/dialamoon/<ISO 8601 UTC, hour precision>
The response is JSON with an "image" object that carries one or more URLs
(varies by year — keys seen include "url", "tif", "1024", etc.). We pick the
largest reasonable JPEG/PNG variant, downsample to MOON_DIALAMOON_TARGET_PX
on save to keep the cache tidy.
Usage as a library:
from moon_dialamoon import fetch_for_time
path = fetch_for_time(datetime(2026, 4, 29, 21, 0, tzinfo=timezone.utc))
# path is a local cached PNG sized to MOON_DIALAMOON_TARGET_PX
CLI:
python3 moon_dialamoon.py 2026-04-29T21:00 # fetch and print path
python3 moon_dialamoon.py 2026-04-29T21:00 --info # also print metadata
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
import urllib.request
from datetime import datetime, timezone
_here = pathlib.Path(__file__).resolve().parent
def _read_conf(path):
conf = {}
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
k = k.strip()
v = v.strip()
v = re.sub(r'\s+#.*$', '', v)
v = v.strip('"').strip("'")
m = re.match(r'^\$\{[^}]+:-([^}]*)\}$', v)
if m:
v = m.group(1).strip('"').strip("'")
conf[k] = v
except FileNotFoundError:
pass
return conf
_CONF = _read_conf(_here / 'sky-cam.conf')
_CONF.update(_read_conf(_here / '.env'))
API_BASE = _CONF.get('MOON_DIALAMOON_API', 'https://svs.gsfc.nasa.gov/api/dialamoon')
CACHE_DIR = pathlib.Path(_CONF.get('MOON_DIALAMOON_CACHE_DIR') or (_here / 'moon-ref' / 'dialamoon'))
TARGET_PX = int(_CONF.get('MOON_DIALAMOON_TARGET_PX', 2048))
USER_AGENT = _CONF.get('MOON_DIALAMOON_USER_AGENT', 'sky-cam/1.0 (+https://github.com/outis1one/sky-cam)')
TIMEOUT = int(_CONF.get('MOON_DIALAMOON_TIMEOUT_SEC', 30))
def _hour_key(dt: datetime) -> str:
"""Cache key — ISO hour, no minutes/seconds. Matches API resolution."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.astimezone(timezone.utc)
return dt.strftime('%Y-%m-%dT%H:00')
def _cache_path(dt: datetime) -> pathlib.Path:
return CACHE_DIR / f'{_hour_key(dt)}.png'
def _meta_path(dt: datetime) -> pathlib.Path:
return CACHE_DIR / f'{_hour_key(dt)}.json'
def _http_get(url: str, accept: str = '*/*') -> bytes:
req = urllib.request.Request(url, headers={
'User-Agent': USER_AGENT,
'Accept': accept,
})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return r.read()
def _api_query(dt: datetime) -> dict:
url = f'{API_BASE}/{_hour_key(dt)}'
data = _http_get(url, accept='application/json')
return json.loads(data)
def _pick_image_url(meta: dict) -> str | None:
"""Walk the response to find the largest JPG/PNG image URL.
Dial-a-Moon's response shape has shifted over time — different years
publish slightly different keys. We accept any of the documented forms.
"""
img = meta.get('image') or {}
# Candidate URLs in priority order: explicit png/jpg, then anything ending
# in those extensions inside nested fields.
candidates: list[str] = []
if isinstance(img, dict):
for key in ('png', 'jpg', 'jpeg', 'url', '4096', '2048', '1024', '512'):
v = img.get(key)
if isinstance(v, str):
candidates.append(v)
elif isinstance(v, dict):
for vv in v.values():
if isinstance(vv, str):
candidates.append(vv)
# walk all string leaves once more in case the schema changed
for v in img.values():
if isinstance(v, str):
candidates.append(v)
elif isinstance(img, str):
candidates.append(img)
# Prefer png > jpg > tif (we can't decode tif with stdlib, but Pillow can)
def score(u: str) -> int:
u = u.lower()
if u.endswith('.png'):
return 3
if u.endswith('.jpg') or u.endswith('.jpeg'):
return 2
if u.endswith('.tif') or u.endswith('.tiff'):
return 1
return 0
candidates = [c for c in candidates if score(c) > 0]
if not candidates:
return None
candidates.sort(key=score, reverse=True)
return candidates[0]
def fetch_for_time(dt: datetime, force: bool = False) -> pathlib.Path:
"""Return the path to a cached dial-a-moon PNG for hour-of-`dt`.
Downloads + downsamples on first call, cached forever after.
Raises RuntimeError on network / parsing failure.
"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache = _cache_path(dt)
if cache.exists() and not force:
return cache
meta = _api_query(dt)
# Persist the metadata next to the image so we can include phase
# description, libration, etc. in captions later if we want.
_meta_path(dt).write_text(json.dumps(meta, indent=2))
img_url = _pick_image_url(meta)
if not img_url:
raise RuntimeError(
f'Dial-a-Moon API for {_hour_key(dt)} returned no usable image URL.\n'
f'Response keys: {sorted(meta.keys())}'
)
raw = _http_get(img_url)
# Decode + downsample to TARGET_PX so the cache stays manageable
from io import BytesIO
from PIL import Image
try:
LANCZOS = Image.Resampling.LANCZOS
except AttributeError:
LANCZOS = Image.LANCZOS
im = Image.open(BytesIO(raw)).convert('RGB')
if max(im.size) > TARGET_PX:
scale = TARGET_PX / max(im.size)
new_size = (int(im.size[0] * scale), int(im.size[1] * scale))
im = im.resize(new_size, LANCZOS)
im.save(cache, 'PNG')
return cache
def get_metadata(dt: datetime) -> dict | None:
p = _meta_path(dt)
if p.exists():
try:
return json.loads(p.read_text())
except Exception:
return None
return None
def _cli():
p = argparse.ArgumentParser()
p.add_argument('when_utc', help='ISO 8601 UTC, e.g. 2026-04-29T21:00')
p.add_argument('--force', action='store_true')
p.add_argument('--info', action='store_true', help='print metadata too')
args = p.parse_args()
dt = datetime.fromisoformat(args.when_utc.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
path = fetch_for_time(dt, force=args.force)
except Exception as e:
print(f'ERROR: {e}', file=sys.stderr)
return 1
print(path)
if args.info:
meta = get_metadata(dt) or {}
for k in ('phase', 'subsolar_lon', 'subsolar_lat', 'subearth_lon',
'subearth_lat', 'posangle', 'distance', 'j2000_ra', 'j2000_dec'):
if k in meta:
print(f' {k}: {meta[k]}')
return 0
if __name__ == '__main__':
sys.exit(_cli())
+61 -29
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""moon_phase_monthly.py — build the monthly moon-phase composite.
"""moon_phase_monthly.py — build the monthly moon-phase close-up.
Handles three phases, controlled by --phase:
@@ -9,16 +9,17 @@ Handles three phases, controlled by --phase:
Algorithm (per phase):
1. Find the most recent occurrence of the target phase (or honour --target).
2. Scan east frames across the collection window (D-Δb .. D+Δa).
3. Filter to frames where:
quality >= MOON_MIN_QUALITY
altitude_deg >= MOON_MIN_ALTITUDE_DEG
illumination is within the phase's illumination band
waxing-state matches the phase target
4. Pick the qualifying frame closest in time to exact phase UTC.
5. Composite the cached lunar texture into it (real sky + halo from east,
borrowed surface detail from the cached reference).
6. Optionally upload to Mattermost.
2. Scan east frames across the collection window (D-Δb .. D+Δa) and pick
the frame closest in time to exact phase UTC where east successfully
detected the moon and standard quality / altitude / illumination
thresholds are met. East is the WITNESS — it confirms you actually had
a chance to see the moon that night.
3. Round east's capture timestamp to the nearest hour and fetch the NASA
SVS Dial-a-Moon render for that hour. This gives a real-physics moon
image with correct phase, libration and crater shadows.
4. Render full-screen on a black background (moon fills ~92% of frame
height), drop a caption naming the phase / month / capture moment /
attribution, and upload to Mattermost.
Geometry note — first-quarter from east is HARD: at first quarter the moon is
up from noon to midnight, but east only sees the eastern sky, so it captures
@@ -81,8 +82,8 @@ MIN_QUALITY = float(CONF.get('MOON_MIN_QUALITY', CONF.get('MOON_FULL_MIN_QUALITY
MIN_ALTITUDE = float(CONF.get('MOON_MIN_ALTITUDE_DEG', CONF.get('MOON_FULL_MIN_ALTITUDE_DEG', 15.0)))
OUT_W = int(CONF.get('MOON_OUTPUT_W', CONF.get('MOON_FULL_OUTPUT_W', 1920)))
OUT_H = int(CONF.get('MOON_OUTPUT_H', CONF.get('MOON_FULL_OUTPUT_H', 1080)))
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', CONF.get('MOON_FULL_HEIGHT_PCT', 0.70)))
REF_PATH = CONF.get('MOON_REFERENCE_PATH') or str(_here / 'moon-ref' / 'full-moon.jpg')
MOON_PCT = float(CONF.get('MOON_HEIGHT_PCT', 0.92))
REQUIRE_EAST_VERIFY = CONF.get('MOON_REQUIRE_EAST_VERIFY', 'true').lower() != 'false'
PHASE_SPEC = {
@@ -242,6 +243,16 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
tz = _local_tz()
target_local = target_utc.astimezone(tz)
# If east-verification is disabled the user wants a post regardless of
# whether east could see the moon that night. Fetch dial-a-moon for the
# exact phase moment, render full-screen, post. Skips all east scanning.
if not REQUIRE_EAST_VERIFY:
print('MOON_REQUIRE_EAST_VERIFY=false — skipping east scan, using exact phase UTC')
return _render_and_post(phase, spec, target_utc, target_utc, target_local,
cam, dry_run, no_upload, out_path,
witness_text='not requiring east verification (set MOON_REQUIRE_EAST_VERIFY=true to require)')
dates = []
for i in range(-spec['window_before'], spec['window_after'] + 1):
d = (target_local + timedelta(days=i)).date()
@@ -299,8 +310,8 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
best = min(qualifying, key=lambda t: abs(t[1] - target_utc))
path, when_utc, det, alt, illum = best
delta_min = (when_utc - target_utc).total_seconds() / 60.0
local_dt = when_utc.astimezone(tz)
delta_min = (when_utc - target_utc).total_seconds() / 60.0
print(f'picked: {path}')
print(f' when_utc={when_utc.isoformat()} local={local_dt} '
f'altitude={alt:.1f}° illum={illum:.4f} quality={det.quality:.3f} '
@@ -309,25 +320,46 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
if dry_run:
return 0
if not pathlib.Path(REF_PATH).is_file():
msg = (f'Lunar reference image missing at {REF_PATH}. Re-run install.sh '
f'or drop a high-res full moon JPEG there manually.')
_notify(f'{spec["emoji"]} {spec["label"]} — reference missing', msg)
print(msg, file=sys.stderr)
return 3
return _render_and_post(
phase, spec, target_utc, when_utc, local_dt,
cam, dry_run, no_upload, out_path,
witness_text=f'witnessed at {local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")} '
f'({delta_min:+.0f} min from exact {phase})',
)
def _render_and_post(phase, spec, target_utc, when_utc, local_dt, cam,
dry_run, no_upload, out_path, witness_text):
if out_path is None:
out_dir = pathlib.Path(MOVIES_DIR) / cam / _output_subdir(phase)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = str(out_dir / _output_filename(phase, target_utc))
from moon_composite import composite_full_moon
# Fetch the NASA SVS Dial-a-Moon render for the hour east captured the
# moon (or the exact phase moment if east-verification is off). The
# render carries the correct phase, libration and crater shadows for
# that UTC moment — the strongest possible match for what east "saw,"
# and free of the white-blob limitation.
import moon_dialamoon
try:
nasa_path = moon_dialamoon.fetch_for_time(when_utc)
except Exception as e:
msg = (
f'NASA SVS Dial-a-Moon fetch failed for '
f'{when_utc.strftime("%Y-%m-%dT%HZ")}: {e}'
)
_notify(f'{spec["emoji"]} {spec["label"]} — dial-a-moon fetch failed', msg)
print(msg, file=sys.stderr)
return 4
print(f'dial-a-moon: {nasa_path}')
from moon_composite import render_phase_closeup
caption = (
f"{spec['label']}{target_utc.strftime('%B %Y')}"
f"sky-cam {cam} {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')}"
f"sky-cam {cam} {witness_text} — render: NASA SVS Dial-a-Moon"
)
composite_full_moon(
path, det, when_utc, REF_PATH, out_path,
render_phase_closeup(
str(nasa_path), out_path,
output_size=(OUT_W, OUT_H), moon_height_pct=MOON_PCT,
caption=caption,
)
@@ -336,25 +368,25 @@ def run_phase(phase: str, target_utc: datetime | None, cam: str,
if no_upload:
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} (built, not posted)',
f'{out_path} picked {local_dt}, Δtarget {delta_min:+.0f} min',
f'{out_path}{witness_text}',
)
return 0
posted = _post_to_mattermost(
out_path,
f"{spec['emoji']} {spec['label']}{target_utc.strftime('%B %Y')}\n"
f"Captured by sky-cam {cam} at {local_dt.strftime('%Y-%m-%d %H:%M:%S %Z')} "
f"({delta_min:+.0f} min from exact {phase}).",
f"sky-cam {cam} {witness_text}.\n"
f"Surface render from NASA SVS Dial-a-Moon for that hour.",
)
if posted:
_notify(
f'{spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} posted',
f'Picked {local_dt} — Δtarget {delta_min:+.0f} min{out_path}',
f'{witness_text}{out_path}',
)
else:
_notify(
f'FAILED: {spec["emoji"]} {spec["label"]} {target_utc.strftime("%B %Y")} upload',
f'Composite built at {out_path} but Mattermost upload failed.',
f'Image built at {out_path} but Mattermost upload failed.',
)
return 0
+45 -27
View File
@@ -52,12 +52,13 @@
# a tracked sequence, stitches into an mp4
#
# moon-phase-monthly.sh — daily at SCHEDULE_MOON_PHASE (SUNRISE_CAM only)
# no-ops except on phase post-days, when it picks
# the best east frame from the collection window,
# composites a high-res lunar texture into it,
# and posts to Mattermost. Handles full moon,
# first quarter (waxing half), and third quarter
# (waning half).
# no-ops except on phase post-days, when it
# confirms east saw the moon during the
# collection window, fetches NASA SVS Dial-a-Moon
# for that exact UTC hour, renders the moon
# full-screen on a black background, and posts to
# Mattermost. Handles full moon, first quarter
# (waxing half), and third quarter (waning half).
#
#
# install.sh — run once at setup, and again if this file changes
@@ -448,19 +449,23 @@ AMBIENT_RETENTION_DAYS=30 # global default; 0 = keep forever
# 🌕 Full Moon posts D + MOON_FULL_POST_DELAY_DAYS
# 🌓 First Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# 🌗 Third Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# Each picks the frame closest in time to the
# exact phase moment that meets quality / altitude
# / illumination thresholds, composites the
# cached lunar texture into it (sky/halo/angle
# real from east, surface detail borrowed),
# uploads to Mattermost. Output:
# East acts as the WITNESS — it confirms the moon
# was actually visible in your sky during the
# collection window and supplies the timestamp.
# The image itself is a NASA SVS Dial-a-Moon
# render for that exact UTC hour, sized to fill
# the frame on a black background (like the look
# of a long-telephoto shot). Output:
# $MOVIES_DIR/<cam>/full-moons/YYYY-MM-full.jpg
# $MOVIES_DIR/<cam>/first-quarter/YYYY-MM-first-quarter.jpg
# $MOVIES_DIR/<cam>/third-quarter/YYYY-MM-third-quarter.jpg
#
# Honest-by-design: the surface texture is not from your camera (no software
# can recover detail your camera didn't capture). The framing — sky color,
# halo, parallactic angle, exact moment of capture — is all real-from-east.
# Honest-by-design: a 38-px white blob from a wide-field IP camera cannot be
# enhanced into crater detail. The image you see in Mattermost is a NASA
# render for the exact moment east captured the moon — the strongest possible
# match for what your sky actually looked like, with full real-physics
# crater shadows, libration, and phase. East's role is verifying that you
# had a clear view of the moon that night.
#
# Geometry caveat: east only sees the eastern sky. At first quarter the moon
# is up only from noon to midnight, transiting south at sunset, so east only
@@ -475,6 +480,11 @@ MOON_FULL_ENABLED=true
MOON_FIRST_QUARTER_ENABLED=true # set false if daytime-detection misses are noisy
MOON_THIRD_QUARTER_ENABLED=true
# Set false to skip east verification entirely and post the NASA render for
# the exact phase moment regardless of clouds / camera outage. Default true
# means "only post when east actually saw the moon that month."
MOON_REQUIRE_EAST_VERIFY=true
# Nightly tracker tuning ──────────────────────────────────────────────────────
MOON_TRACK_CROP_PX=480 # pixels — box size around the moon (source coords)
MOON_TRACK_FPS=12 # output mp4 framerate
@@ -486,25 +496,33 @@ MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = fo
# nights to be on disk, then runs the morning of D+3.
MOON_FULL_POST_DELAY_DAYS=3
# Quarter (half-moon) tuning ──────────────────────────────────────────────────
# 2 = waits for D-1, D, D+1 nights, runs the morning of D+2.
MOON_QUARTER_POST_DELAY_DAYS=2
MOON_QUARTER_MIN_ILLUMINATION=0.40 # ~50% ± 10% covers the day around exact quarter
MOON_QUARTER_MAX_ILLUMINATION=0.65
# Frame-acceptance thresholds — a candidate must beat all three to qualify.
# Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these.
MOON_FULL_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
MOON_FULL_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
MOON_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
MOON_FULL_MIN_ILLUMINATION=0.95 # 0..1 — ~95% lit covers ±2 days from exact full
# Output frame. Default 1920×1080 to match the sunrise videos.
MOON_FULL_OUTPUT_W=1920
MOON_FULL_OUTPUT_H=1080
MOON_FULL_HEIGHT_PCT=0.70 # moon disk fills this fraction of frame height
MOON_OUTPUT_W=1920
MOON_OUTPUT_H=1080
MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame height (full-screen feel)
# Lunar reference texture — downloaded once by install.sh, reused forever.
# Override MOON_REFERENCE_URL in .env to use a different image. Any high-res
# photo of a full moon on a black background works. Default is the Wikipedia
# "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500.
#MOON_REFERENCE_URL=https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg
#MOON_REFERENCE_DIR="$SCRIPT_DIR/moon-ref"
#MOON_REFERENCE_PATH="$MOON_REFERENCE_DIR/full-moon.jpg"
# NASA SVS Dial-a-Moon ────────────────────────────────────────────────────────
# Hourly pre-rendered moon images, free, public domain, real physics
# (correct phase, libration, crater shadows for any UTC hour).
# https://svs.gsfc.nasa.gov/api/dialamoon/<ISO-DATE>
# We hit it once per phase event (~36 calls/year), cache the result forever.
#MOON_DIALAMOON_API=https://svs.gsfc.nasa.gov/api/dialamoon
#MOON_DIALAMOON_CACHE_DIR="$SCRIPT_DIR/moon-ref/dialamoon"
MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30
# ── Mattermost — daily sunrise upload ─────────────────────────────────────────
# mattermost_url, access_token, channel_id go in .env (see bottom of this file).