Skill: dyagil/dyagil-marathon-clip (version 1.0.0)

--- name: dyagil-marathon-clip description: Render a 30-second vertical (1080x1920) running-recap video from per-workout data using Remotion + ffmpeg + royalty-free music. Use when the user wants a Strava/Garmin-style weekly recap clip, a workout video for Stories/Twitter/WhatsApp, or wants to share their marathon-training progress as a short shareable MP4. version: 1.0.0 license: MIT author: dyagil --- # Marathon Clip — Weekly Running Recap > This skill is tuned for a specific personal data source (a local SQLite DB populated from Garmin Connect). The Remotion + ffmpeg pipeline and the visual composition are fully reusable — fork the data-extraction step and point it at your own source (Strava API, Apple Health export, CSV, etc.). Render a 30-second 9:16 video summarizing the last week (or N days) of running activity. The output is a Stories-ready MP4 with animated stats, per-run bars, a stroke-animated GPS map, and royalty-free background music. ## When to Use Use this skill when the request matches: - "Make a clip of this week's runs" - "Weekly running summary video" / "Marathon recap" - "Strava-style story" / "Stories video of my runs" Do **not** use this for: - Non-running activities (strength, swim) — needs a different template. - Generic video generation without per-workout data — use a general-purpose Remotion toolkit instead. ## What It Does Pulls the last N days of `type='run'` workouts from a local SQLite DB (`<project>/data/sahi.db` by default), downloads GPX tracks via the Garmin Connect API, writes both to JSON, and renders **six animated sequence types**: 1. **Intro** (4s) — Animated title with athlete name + period. 2. **Big Stats** (9s) — Count-up animations for km / hours / kcal / runs. 3. **Map Scenes** (4s each) — One per run with GPS: animated stroke-dash polyline, pulsing runner marker along the path, start/finish markers, per-run footer. 4. **Trends** (8s) — Side-by-side comparison vs the prior week with arrows (▲ better / ▼ worse), an overall verdict (CRUSHING IT / STEADY / RECOVERY / EASIER WEEK), and per-metric deltas in %. 5. **Runs Breakdown** (8s) — Average pace + horizontal bars per run. 6. **Outro** (5s) — Closing card with totals. Total length: typically 30–60s depending on number of runs with GPS data. A fade-in/fade-out royalty-free track plays through (`public/music.mp3`). ## Run It ```bash ~/bin/marathon-clip # last 7 days, default music, → out/marathon-week.mp4 ~/bin/marathon-clip --days 14 ~/bin/marathon-clip --out /tmp/x.mp4 ~/bin/marathon-clip --no-music ~/bin/marathon-clip --music other.mp3 ~/bin/marathon-clip --send-telegram # render and post to Telegram (requires openclaw CLI) ``` Default project location: `<workspace>/projects/sahi-video/`. ### Manual weekly invocation ```bash bash <skills-dir>/marathon-clip/scripts/weekly.sh ``` Always renders the **previous Monday→Sunday** window regardless of when invoked, and writes `out/weekly-YYYY-MM-DD.mp4`. The last line of stdout is `MARATHON_CLIP_OUT=<absolute path>`. ### Automated weekly delivery (optional) If you use the OpenClaw cron system, register a job to run `scripts/weekly.sh` every Monday 09:00 in your timezone. Manage it via: ```bash openclaw cron list openclaw cron show <id> openclaw cron enable <id> / disable <id> openclaw cron run <id> # fire now (test) ``` The cron payload is a system event addressed to the main agent session; the agent runs `weekly.sh` and posts the resulting MP4. ## Customization Knobs ### Visual style Edit `<project>/src/MarathonClip.tsx`: - **Colors:** the green gradient lives in `Background` (~line 40). Replace with brand colors. - **Logo:** `Logo` component (top right). - **Layout:** each sequence is its own component (`Intro`, `BigStats`, `RunsBreakdown`, `Outro`). ### Music Drop an mp3 into `<project>/public/` and pass `--music your-file.mp3`. To trim/fade a new track: ```bash ffmpeg -y -i source.mp3 -ss 0 -t 30 \ -af "afade=t=in:st=0:d=0.5,afade=t=out:st=28.5:d=1.5" \ -ac 2 -ar 44100 -b:a 192k music.mp3 ``` ### Map / GPS Map scenes are pure SVG with custom lat/lon projection (longitude scaled by `cos(meanLat)` for correctness). No tile loading, no external map service — fully headless. The polyline animates via `stroke-dasharray` + `stroke-dashoffset` for the drawing effect, with a pulsing marker following the head of the line. To regenerate just the tracks without re-rendering: `node scripts/build-tracks.cjs --days 7`. ### Data source The extractor (`scripts/build-data.cjs`) runs: ```sql SELECT local_date, duration_min, distance_km, calories, notes FROM workouts WHERE type='run' AND local_date >= date('now','-7 day') ORDER BY local_date ASC ``` **Adapting this skill:** rewrite `build-data.cjs` to query your own data source (Strava, Apple Health export, CSV). It must produce a `data.json` with this shape: ```json { "athlete": "Name", "period": "May 8 – May 15", "totals": {"km": 42.1, "hours": 4.2, "kcal": 3100, "runs": 5}, "runs": [ {"date": "2026-05-09", "km": 8.2, "pace": "5:12/km", "calories": 620, "gpx": "...optional polyline data..."} ], "prev": { "...same shape, previous week..." } } ``` ## Dependencies - **Node.js** + `npm` (Remotion v4) - **ffmpeg** (music trim/fade) - **Chrome Headless Shell** — auto-installed by Remotion on first render - **Linux libs** for headless Chrome: `libnss3 libatk1.0-0 libgbm1 libxdamage1` If anything's missing, a sibling `remotion-server` skill has `scripts/setup.sh` that installs the apt deps. ## Render Time + Cost - ~70–90 seconds on a 2-core VPS at `concurrency=2`. - Output size: ~3 MB MP4 (H.264 + AAC stereo). - No external API calls during render — everything local. ## Delivery (OpenClaw note) ⚠️ Some chat delivery channels only accept files under `~/.openclaw/media/outbound/`. Files under `<project>/out/` won't deliver. `weekly.sh` already copies the final MP4 to the outbound dir with a UUID. Use the `MARATHON_CLIP_DELIVER` env line (not `MARATHON_CLIP_OUT`) when assembling the `MEDIA:` line for chat delivery. ## Troubleshooting - **"no such table: activities"** — the schema uses `workouts`, not `activities`. Adjust your `build-data.cjs` query. - **Music doesn't play** — Remotion v4 needs `staticFile()` and the file must be in `public/`. Use the `Audio` component, not `<audio>`. - **"Cannot find module 'zod'"** — run `npm install zod` in the project root. - **Render hangs at "Spinning up browser"** — first run only, downloading Chrome Headless Shell (~200 MB). - **Empty data** — check the date filter; if no runs synced recently the output will be empty. Trigger your data sync first. ## Files ``` <skills-dir>/marathon-clip/ SKILL.md scripts/ build-data.cjs ← SQL → data.json (customize for your data source) render.sh ← end-to-end pipeline references/ musical-sources.md ← curated list of CC/PD music sources <project>/sahi-video/ src/ ← Remotion composition public/music.mp3 ← 30s royalty-free track data.json ← generated each run out/ ← rendered MP4s ~/bin/marathon-clip ← symlink to scripts/render.sh ```

path: references/musical-sources.md
content: # Royalty-Free Music Sources for marathon-clip

The default track is from archive.org and is CC/PD licensed. Other reliable sources for swap-in music:

## archive.org (direct mp3 download works)
Use the `ia*.us.archive.org` mirror, not `archive.org/download/...` (the latter often returns 500 from server IPs).

```bash
# Discover files in a collection
curl -sL "https://archive.org/metadata/<identifier>" | jq '.files[] | select(.name | endswith(".mp3"))'

# Download (use the dir field for the mirror)
curl -sL -A "Mozilla/5.0" -o music-full.mp3 \
  "https://ia<NNN>.us.archive.org/<server>/items/<identifier>/<filename>.mp3"
```

Useful collections:
- `UpbeatAndHappyBackgroundMusic` — used as default
- `jamendo-418443` — DepasRec "Upbeat positive motivational electronic"
- `jamendo-564783` — DHDMusic "Corporate Upbeat Motivation 30sec Loop"
- `freemusicarchive` — the FMA collection mirror

## Free Music Archive
https://freemusicarchive.org/ — needs HTML scraping for the actual `.mp3` URL.

## Pixabay / Chosic / Mixkit
Blocked from server IPs (Cloudflare / hotlinking). Download locally and `scp` to the server, or use a residential proxy.

## Trim + fade with ffmpeg

```bash
ffmpeg -y -i source.mp3 -ss 0 -t 30 \
  -af "afade=t=in:st=0:d=0.5,afade=t=out:st=28.5:d=1.5" \
  -ac 2 -ar 44100 -b:a 192k music.mp3
```

Place the result at `~/.openclaw/workspace/projects/sahi-video/public/music.mp3` (the Remotion `staticFile()` path).

## Licensing checklist before using

- Confirm CC0 / CC-BY / PD on the source page
- For CC-BY: add a credit line in the video outro or caption
- Re-check yearly — public-domain claims sometimes get challenged


path: scripts/build-data.cjs
content: #!/usr/bin/env node
/**
 * build-data.cjs — Extract running stats from sahi-diet for a specific window
 * (default: last 7 days) AND for the comparison window (previous 7 days)
 * to enable the trends/improvements scene in MarathonClip.
 *
 * Usage:
 *   node build-data.cjs [--days 7] [--from YYYY-MM-DD] [--to YYYY-MM-DD]
 *                       [--out data.json] [--athlete "David"]
 *
 * Date logic:
 *   - With --from / --to: explicit window. Compare to the equal-length window
 *     immediately before it.
 *   - With --days N (default): last N days inclusive of today. Compare to the
 *     previous N days before that.
 */
const path = require('path');
const fs = require('fs');
const os = require('os');

const HOME = os.homedir();
const DEFAULT_DB = path.join(HOME, '.openclaw/workspace/projects/sahi-diet/data/sahi.db');
const DEFAULT_OUT = path.join(HOME, '.openclaw/workspace/projects/sahi-video/data.json');

function arg(name, def) {
  const i = process.argv.indexOf('--' + name);
  if (i === -1) return def;
  return process.argv[i + 1] ?? def;
}

function isoDate(d) {
  return d.toISOString().slice(0, 10);
}
function addDays(iso, n) {
  const d = new Date(iso + 'T00:00:00Z');
  d.setUTCDate(d.getUTCDate() + n);
  return isoDate(d);
}
function daysBetween(a, b) {
  const da = new Date(a + 'T00:00:00Z'), db = new Date(b + 'T00:00:00Z');
  return Math.round((db - da) / (24 * 3600 * 1000)) + 1;
}

const days = parseInt(arg('days', '7'), 10);
const from = arg('from', null);
const to = arg('to', null);
const out = arg('out', DEFAULT_OUT);
const athlete = arg('athlete', 'David');
const dbPath = arg('db', DEFAULT_DB);

let curFrom, curTo;
if (from && to) { curFrom = from; curTo = to; }
else {
  curTo = isoDate(new Date());
  curFrom = addDays(curTo, -(days - 1));
}
const windowLen = daysBetween(curFrom, curTo);
const prevTo = addDays(curFrom, -1);
const prevFrom = addDays(prevTo, -(windowLen - 1));

if (!fs.existsSync(dbPath)) {
  console.error(`build-data: database not found at ${dbPath}`);
  process.exit(1);
}

let Database;
try {
  Database = require(path.join(HOME, '.openclaw/workspace/projects/sahi-diet/node_modules/better-sqlite3'));
} catch {
  try { Database = require('better-sqlite3'); }
  catch { console.error('better-sqlite3 missing'); process.exit(1); }
}

const db = new Database(dbPath, { readonly: true });

function fetchWindow(a, b) {
  return db
    .prepare(
      `SELECT local_date, duration_min, distance_km, calories, notes
       FROM workouts
       WHERE type='run' AND local_date >= ? AND local_date <= ?
       ORDER BY local_date ASC`
    )
    .all(a, b);
}

function summarize(rows) {
  const km = rows.reduce((s, r) => s + (r.distance_km || 0), 0);
  const min = rows.reduce((s, r) => s + (r.duration_min || 0), 0);
  const cal = rows.reduce((s, r) => s + (r.calories || 0), 0);
  const pace = km > 0 ? min / km : 0;
  return {
    total_km: +km.toFixed(2),
    total_min: min,
    total_hours: +(min / 60).toFixed(1),
    total_calories: cal,
    avg_pace_min_per_km: +pace.toFixed(2),
    run_count: rows.length,
  };
}

function diff(cur, prev, key, lowerIsBetter = false) {
  const c = cur[key], p = prev[key];
  if (p === 0) return { absolute: c, percent: null, better: c > 0 };
  const delta = c - p;
  const pct = (delta / p) * 100;
  const better = lowerIsBetter ? delta < 0 : delta > 0;
  return { absolute: +delta.toFixed(2), percent: +pct.toFixed(1), better };
}

const curRows = fetchWindow(curFrom, curTo);
const prevRows = fetchWindow(prevFrom, prevTo);
const cur = summarize(curRows);
const prev = summarize(prevRows);

// Hebrew period label
function hebrewPeriodLabel(from, to, len) {
  if (len === 7) {
    return 'שבוע אחרון';
  }
  return `${len} ימים אחרונים`;
}

const data = {
  athlete,
  period: hebrewPeriodLabel(curFrom, curTo, windowLen),
  window: { from: curFrom, to: curTo },
  prev_window: { from: prevFrom, to: prevTo },
  ...cur,
  runs: curRows.map((r) => ({
    date: r.local_date,
    km: +(r.distance_km || 0).toFixed(2),
    min: r.duration_min,
    note: (r.notes || '').slice(0, 40),
  })),
  trends: {
    has_prev: prev.run_count > 0,
    prev: prev,
    km: diff(cur, prev, 'total_km'),                // higher = better
    minutes: diff(cur, prev, 'total_min'),          // higher = better
    runs: diff(cur, prev, 'run_count'),             // higher = better
    pace: diff(cur, prev, 'avg_pace_min_per_km', true), // lower = better
  },
};

fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(data, null, 2));
console.log(`build-data: cur ${cur.run_count} runs / ${cur.total_km} km (${curFrom}→${curTo})`);
console.log(`            prev ${prev.run_count} runs / ${prev.total_km} km (${prevFrom}→${prevTo})`);
console.log(`            → ${out}`);


path: scripts/build-tracks.cjs
content: #!/usr/bin/env node
/**
 * build-tracks.cjs — Fetch GPX tracks from Garmin for runs in the last N days,
 * parse them, simplify with Douglas-Peucker, and write tracks.json.
 *
 * Usage:
 *   node build-tracks.cjs [--days 7] [--out tracks.json]
 */
const path = require('path');
const fs = require('fs');
const os = require('os');

const HOME = os.homedir();
const SAHI_DIET = path.join(HOME, '.openclaw/workspace/projects/sahi-diet');
const DEFAULT_OUT = path.join(HOME, '.openclaw/workspace/projects/sahi-video/tracks.json');
const TMP_GPX = '/tmp/gpx-out-marathon-clip';

function arg(name, def) {
  const i = process.argv.indexOf('--' + name);
  if (i === -1) return def;
  return process.argv[i + 1] ?? def;
}

const days = parseInt(arg('days', '7'), 10);
const out = arg('out', DEFAULT_OUT);

// --- Pull run metadata from sahi-diet DB ---
let Database;
try { Database = require(path.join(SAHI_DIET, 'node_modules/better-sqlite3')); }
catch { try { Database = require('better-sqlite3'); } catch { console.error('better-sqlite3 missing'); process.exit(1); } }

const db = new Database(path.join(SAHI_DIET, 'data/sahi.db'), { readonly: true });
const runs = db
  .prepare(
    `SELECT external_id, local_date, distance_km, duration_min, notes
     FROM workouts
     WHERE type='run' AND source='garmin' AND external_id IS NOT NULL
       AND local_date >= date('now', '-' || ? || ' day')
     ORDER BY local_date ASC`
  )
  .all(days);

if (runs.length === 0) {
  console.log('no garmin runs in last', days, 'days');
  fs.writeFileSync(out, '[]');
  process.exit(0);
}

// --- Fetch GPX via Garmin (requires .env at sahi-diet root) ---
(async () => {
  // Load env from sahi-diet/.env
  require(path.join(SAHI_DIET, 'node_modules/dotenv')).config({ path: path.join(SAHI_DIET, '.env') });
  const { GarminConnect } = require(path.join(SAHI_DIET, 'node_modules/garmin-connect'));
  const gc = new GarminConnect({ username: process.env.GARMIN_EMAIL, [REDACTED_SECRET] });
  await gc.login();

  fs.rmSync(TMP_GPX, { recursive: true, force: true });
  fs.mkdirSync(TMP_GPX, { recursive: true });

  for (const r of runs) {
    try {
      await gc.downloadOriginalActivityData({ activityId: parseInt(r.external_id, 10) }, TMP_GPX, 'gpx');
      console.error('GPX', r.external_id, 'OK');
    } catch (e) {
      console.error('GPX', r.external_id, 'FAIL:', e.message);
    }
  }

  // --- Parse and simplify ---
  function douglasPeucker(pts, eps) {
    if (pts.length < 3) return pts;
    function perp(p, a, b) {
      const dx = b[1] - a[1], dy = b[0] - a[0];
      if (dx === 0 && dy === 0) return Math.hypot(p[1] - a[1], p[0] - a[0]);
      const t = ((p[1] - a[1]) * dx + (p[0] - a[0]) * dy) / (dx * dx + dy * dy);
      if (t < 0) return Math.hypot(p[1] - a[1], p[0] - a[0]);
      if (t > 1) return Math.hypot(p[1] - b[1], p[0] - b[0]);
      return Math.hypot(p[1] - (a[1] + t * dx), p[0] - (a[0] + t * dy));
    }
    let max = 0, idx = 0;
    for (let i = 1; i < pts.length - 1; i++) {
      const d = perp(pts[i], pts[0], pts[pts.length - 1]);
      if (d > max) { max = d; idx = i; }
    }
    if (max > eps) {
      const L = douglasPeucker(pts.slice(0, idx + 1), eps);
      const R = douglasPeucker(pts.slice(idx), eps);
      return L.slice(0, -1).concat(R);
    }
    return [pts[0], pts[pts.length - 1]];
  }

  const tracks = [];
  for (const r of runs) {
    const file = path.join(TMP_GPX, `${r.external_id}.gpx`);
    if (!fs.existsSync(file)) continue;
    const xml = fs.readFileSync(file, 'utf8');
    const re = /<trkpt\s+lat="([\d.\-]+)"\s+lon="([\d.\-]+)">/g;
    const pts = [];
    let m;
    while ((m = re.exec(xml)) !== null) pts.push([parseFloat(m[1]), parseFloat(m[2])]);
    if (pts.length === 0) {
      console.error('skip', r.external_id, '(no GPS - probably treadmill)');
      continue;
    }
    const simplified = douglasPeucker(pts, 0.00005);
    const lats = simplified.map((p) => p[0]);
    const lons = simplified.map((p) => p[1]);
    tracks.push({
      id: String(r.external_id),
      date: r.local_date,
      name: (r.notes || '').slice(0, 40) || `Run ${r.local_date}`,
      km: +Number(r.distance_km || 0).toFixed(2),
      min: r.duration_min,
      bounds: {
        minLat: Math.min(...lats), maxLat: Math.max(...lats),
        minLon: Math.min(...lons), maxLon: Math.max(...lons),
      },
      points: simplified.map((p) => [+p[0].toFixed(6), +p[1].toFixed(6)]),
    });
    console.error(r.external_id, '→', simplified.length, 'points');
  }

  tracks.sort((a, b) => a.date.localeCompare(b.date));
  fs.mkdirSync(path.dirname(out), { recursive: true });
  fs.writeFileSync(out, JSON.stringify(tracks, null, 2));
  console.log(`build-tracks: ${tracks.length} tracks → ${out}`);
})().catch((e) => { console.error('build-tracks failed:', e.message); process.exit(1); });


path: scripts/render.sh
content: #!/usr/bin/env bash
# marathon-clip — Render a weekly running recap video.
#
# Usage:
#   marathon-clip [--days N] [--out PATH] [--no-music] [--music FILE] [--send-telegram]
set -euo pipefail

DAYS=7
OUT=""
NO_MUSIC=0
MUSIC=""
SEND_TG=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --days) DAYS="$2"; shift 2;;
    --out) OUT="$2"; shift 2;;
    --no-music) NO_MUSIC=1; shift;;
    --music) MUSIC="$2"; shift 2;;
    --send-telegram) SEND_TG=1; shift;;
    -h|--help)
      sed -n '2,12p' "$0"; exit 0;;
    *) echo "marathon-clip: unknown arg $1" >&2; exit 2;;
  esac
done

PROJ="$HOME/.openclaw/workspace/projects/sahi-video"
SKILL_DIR="$HOME/.openclaw/workspace/skills/marathon-clip"
[[ -z "$OUT" ]] && OUT="$PROJ/out/marathon-week.mp4"

if [[ ! -d "$PROJ" ]]; then
  echo "marathon-clip: project not found at $PROJ" >&2
  echo "  See SKILL.md → first-time setup." >&2
  exit 1
fi

echo "[1/4] Building stats data from sahi-diet (last ${DAYS} days)…"
node "$SKILL_DIR/scripts/build-data.cjs" --days "$DAYS" --out "$PROJ/data.json"

echo "[2/4] Fetching GPX tracks from Garmin…"
if ! node "$SKILL_DIR/scripts/build-tracks.cjs" --days "$DAYS" --out "$PROJ/tracks.json"; then
  echo "  (failed; falling back to existing tracks.json if any)" >&2
  [[ -f "$PROJ/tracks.json" ]] || echo "[]" > "$PROJ/tracks.json"
fi

# Handle music
if [[ $NO_MUSIC -eq 1 ]]; then
  # Temporarily move music out of public/ so Remotion can't find it
  if [[ -f "$PROJ/public/music.mp3" ]]; then
    mv "$PROJ/public/music.mp3" "$PROJ/public/.music.mp3.bak"
  fi
  trap 'if [[ -f "$PROJ/public/.music.mp3.bak" ]]; then mv "$PROJ/public/.music.mp3.bak" "$PROJ/public/music.mp3"; fi' EXIT
elif [[ -n "$MUSIC" ]]; then
  if [[ ! -f "$MUSIC" ]]; then echo "marathon-clip: music file not found: $MUSIC" >&2; exit 1; fi
  cp "$MUSIC" "$PROJ/public/music.mp3"
fi

echo "[3/4] Rendering with Remotion…"
mkdir -p "$(dirname "$OUT")"
cd "$PROJ"
npx remotion render src/index.ts MarathonClip "$OUT" --log=info

echo "[4/4] Done. → $OUT"
ls -la "$OUT"

if [[ $SEND_TG -eq 1 ]]; then
  echo "[4/4] Sending to Telegram…"
  if command -v openclaw >/dev/null 2>&1; then
    openclaw message send --channel telegram --to 6034574482 --media "$OUT" --text "🏃‍♂️ Marathon recap — last $DAYS days" || true
  else
    echo "marathon-clip: openclaw CLI not in PATH; skipped telegram send" >&2
  fi
fi


path: scripts/weekly.sh
content: #!/usr/bin/env bash
# weekly.sh — Render and deliver the weekly marathon clip.
#
# Always renders the previous Mon→Sun week (week that just ended yesterday
# if today is Monday in Asia/Jerusalem). Falls back to last-7-days if the
# computation produces an empty range.
set -euo pipefail

SKILL_DIR="$HOME/.openclaw/workspace/skills/marathon-clip"
PROJ="$HOME/.openclaw/workspace/projects/sahi-video"

# Compute "last Monday → last Sunday" in Asia/Jerusalem.
# date supports %u (1=Mon..7=Sun) so we offset from today.
TZ="Asia/Jerusalem"
export TZ

TODAY=$(date +%Y-%m-%d)
DOW=$(date +%u)                # 1..7

# If today is Monday (1), the just-ended week is yesterday-6 .. yesterday.
# Otherwise compute the most recent Sunday and step back 6 days to that Monday.
if [[ "$DOW" == "1" ]]; then
  END=$(date -d "$TODAY -1 day" +%Y-%m-%d)
else
  END=$(date -d "$TODAY -$DOW day +1 day" +%Y-%m-%d 2>/dev/null)
  # fallback simple computation: days_back = DOW
  END=$(date -d "$TODAY -$DOW day" +%Y-%m-%d)
fi
START=$(date -d "$END -6 day" +%Y-%m-%d)
echo "[weekly] window: $START → $END (today=$TODAY, dow=$DOW)"

OUT_DIR="$PROJ/out"
OUT="$OUT_DIR/weekly-$END.mp4"
mkdir -p "$OUT_DIR"

echo "[weekly] [1/3] Building data…"
node "$SKILL_DIR/scripts/build-data.cjs" --from "$START" --to "$END" --out "$PROJ/data.json"

echo "[weekly] [2/3] Fetching GPX tracks…"
if ! node "$SKILL_DIR/scripts/build-tracks.cjs" --days 7 --out "$PROJ/tracks.json"; then
  echo "[weekly]   (tracks fetch failed; using empty)" >&2
  echo "[]" > "$PROJ/tracks.json"
fi

# Re-trim music to a longer duration matching the expected video length.
# total = 4 + 9 + N*4 + 8 + 8 + 5; bound to [40, 70].
N_TRACKS=$(node -e "console.log(JSON.parse(require('fs').readFileSync(process.argv[1])).length)" "$PROJ/tracks.json")
DURATION=$((4 + 9 + N_TRACKS * 4 + 8 + 8 + 5))
MUSIC_LEN=$((DURATION + 5))
if [[ -f "$PROJ/public/music-full.mp3" ]]; then
  ffmpeg -y -loglevel error -i "$PROJ/public/music-full.mp3" -ss 0 -t "$MUSIC_LEN" \
    -af "afade=t=in:st=0:d=0.5,afade=t=out:st=$((MUSIC_LEN - 2)):d=2" \
    -ac 2 -ar 44100 -b:a 192k "$PROJ/public/music.mp3"
fi

echo "[weekly] [3/3] Rendering ($DURATION s)…"
cd "$PROJ"
npx remotion render src/index.ts MarathonClip "$OUT" --log=info

echo "[weekly] Output: $OUT"
ls -la "$OUT"

# Copy to OpenClaw managed outbound directory so MEDIA: delivery works.
OUTBOUND_DIR="$HOME/.openclaw/media/outbound"
mkdir -p "$OUTBOUND_DIR"
UUID=$(python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null || cat /proc/sys/kernel/random/uuid)
DELIVER="$OUTBOUND_DIR/weekly-${END}---${UUID}.mp4"
cp "$OUT" "$DELIVER"
echo "[weekly] Delivery copy: $DELIVER"

# Emit BOTH the source path (for debugging) and the delivery path (the one to ship).
# Downstream agents should prefer MARATHON_CLIP_DELIVER for MEDIA: lines.
echo "MARATHON_CLIP_OUT=$OUT"
echo "MARATHON_CLIP_DELIVER=$DELIVER"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)