Skill: mutonby/autoshorts (version 2.0.3)

--- name: autoshorts description: "Turn long videos into viral TikTok, Instagram Reels & YouTube Shorts. Daily AI pipeline: Whisper transcribes, Gemini 3 Flash multimodal picks every viral moment with frame-accurate cuts, FFmpeg renders with hook-text overlay, you approve, Upload-Post publishes. One video per run, human-gated before posting. Use when the user wants to create shorts/reels/clips from long videos, mentions autoshorts, viral clips, video repurposing, content automation, or asks for the daily clip batch." license: MIT compatibility: "Requires ffmpeg, Python 3.11+, faster-whisper, google-genai SDK, Pillow, and internet access for the Gemini and Upload-Post APIs. Designed to run inside an agent harness (Hermes / Openclaw / Claude Code) — works headless on a VPS." metadata: author: mutonby version: "2.0.3" homepage: "https://github.com/mutonby/skill-autoshorts" primaryEnv: UPLOAD_POST_API_KEY requires: bins: [ffmpeg, python3] env: [UPLOAD_POST_API_KEY, UPLOAD_POST_PROFILE, GEMINI_API_KEY] envVars: - { name: UPLOAD_POST_API_KEY, required: true, description: "Upload-Post API key (https://app.upload-post.com → Settings → API Keys)" } - { name: UPLOAD_POST_PROFILE, required: true, description: "Upload-Post profile name with the connected TikTok / Instagram / YouTube accounts" } - { name: GEMINI_API_KEY, required: true, description: "Google Gemini API key for multimodal candidate selection (Gemini 3 Flash)" } - { name: INPUT_FOLDER, required: false, description: "Absolute path where long videos arrive (default: <skill>/input)" } - { name: OUTPUT_FOLDER, required: false, description: "Absolute path where clip output is written (default: <skill>/output)" } - { name: WHISPER_MODEL, required: false, description: "Whisper model size: tiny|base|small|medium|large (default: medium)" } - { name: TIMEZONE, required: false, description: "IANA timezone for daily scheduling (default: Europe/Madrid)" } --- # AutoShorts — Daily Viral Clip Pipeline Pipeline tooling lives at `~/Documents/skill-autoshorts/`. Each day this skill picks ONE long video from `INPUT_FOLDER`, extracts every viable short-form clip (Gemini 3 Flash decides), shows them to the user for approval, and publishes the approved ones via Upload-Post. ## Setup (only if not yet configured) ### 1. Python environment ```bash cd ~/Documents/skill-autoshorts && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt ``` ### 2. FFmpeg Required system binary. Verify with `ffmpeg -version`. Install with `brew install ffmpeg` if missing. ### 3. `.env` File lives at `~/Documents/skill-autoshorts/.env`. Required keys: ``` UPLOAD_POST_API_KEY=... UPLOAD_POST_PROFILE=... GEMINI_API_KEY=... INPUT_FOLDER=/abs/path/to/long/videos OUTPUT_FOLDER=/abs/path/to/clip/output WHISPER_MODEL=medium TIMEZONE=Europe/Madrid ``` If a required key is missing, ask the user for it before continuing. ### 4. Upload-Post account - Sign up at https://upload-post.com → dashboard at https://app.upload-post.com. - Connect TikTok, Instagram (Business/Creator account linked to a Facebook Page), and YouTube via OAuth in the dashboard. - In **Manage Users**, create a profile — its name is `UPLOAD_POST_PROFILE` (NOT the social handle). - Generate an API key in **Settings**. - Verify: `curl -H "Authorization: Apikey $UPLOAD_POST_API_KEY" https://api.upload-post.com/api/uploadposts/me`. ## Orchestration model This skill is invoked daily by the **openclaw** harness, which also handles the messaging bridge (Telegram, WhatsApp, or whatever channel openclaw is configured with). The skill itself does NOT talk to Telegram or any messenger directly — it just runs the pipeline and presents the candidates as text + absolute file paths. openclaw forwards your output to the user's phone, captures the user's reply, and feeds it back into the conversation. Concretely: at Step 5 you print the candidates table and ask which IDs to publish; openclaw delivers that table plus the clip files via the user's chosen channel; the user replies on their phone (e.g., "1, 3, 5"); openclaw injects that reply back; you continue with Steps 6–8. Same pattern for any other "ask the user" point in the workflow (metadata review, dry-run confirmation, etc.). If the skill is invoked outside openclaw (e.g., user runs `/autoshorts` directly in Claude Code), the same prompts work — they just appear in the terminal instead of on the phone. ## Daily workflow This skill is meant to run as a **daily infinite loop**. Every run picks ONE video and walks it through the pipeline. Pick semantics are **round-robin per cycle**: each video is picked at most once per cycle. When every video in `INPUT_FOLDER` has been processed in the current cycle, a new cycle automatically starts and the same videos become available again — generating fresh clips from already-clipped sources. The state file at `state/processed.json` tracks `cycle_started_at`, `last_processed_at` per video, and `cycles_count` per video. Inside a cycle, the next pick is the newest unprocessed-this-cycle (mtime DESC), so fresh material always jumps the queue. ### Step 0 — Preflight (run on every invocation, do not skip) Before doing any work, check that the environment is ready and ask the user for whatever is missing: 1. **venv** — does `~/Documents/skill-autoshorts/venv/bin/python` exist? If not, run setup step 1 from the Setup section. (You can do this without asking — it's mechanical.) 2. **`ffmpeg`** in `PATH` — if missing, ask the user to `brew install ffmpeg` (do not install yourself; system-wide installs deserve confirmation). 3. **`.env` file** — check that every required key is set and non-empty: - `GEMINI_API_KEY` → if missing, ask: *"Falta la API key de Gemini. Pégamela (la generas en https://aistudio.google.com/apikey)."* - `UPLOAD_POST_API_KEY` and `UPLOAD_POST_PROFILE` → if missing, ask: *"Necesito la API key de Upload-Post y el nombre del profile (Manage Users en https://app.upload-post.com)."* - `INPUT_FOLDER` and `OUTPUT_FOLDER` → if missing, default to `~/Documents/skill-autoshorts/input` and `~/Documents/skill-autoshorts/output` and write them to `.env`. - `WHISPER_MODEL` → default `medium`. `TIMEZONE` → default `Europe/Madrid`. If the user provides an API key in the conversation, write it to `.env` immediately, never echo it back, and **warn that the key is now in conversation logs and they should rotate it after testing.** **Input video format**: videos in `INPUT_FOLDER` are expected to already be 9:16 vertical and ready to post (1080×1920 typical). If the user has burned-in subtitles, those should already be on the source video. The skill does NOT reformat, crop, scale, or burn subtitles — it only cuts the chosen segment and overlays a hook text on top. If a video arrives in landscape or any non-9:16 ratio, surface that to the user and ask before processing — running the pipeline as-is will produce TikTok-incompatible output. **How videos arrive into `INPUT_FOLDER`** is the harness's job, not the skill's. The canonical flow: the user forwards a video to openclaw / Hermes / their agent in chat (Telegram / WhatsApp / etc.), the harness downloads it and saves it to `INPUT_FOLDER`. The skill itself only operates on files that are already there. If the user passes a video path that is NOT inside `INPUT_FOLDER` (e.g. `/autoshorts /Users/foo/Downloads/podcast.mp4`), copy it in first (use `cp`, do not move — the original stays put). Otherwise `pick` will not find it. ### Step 1 — Pick the video ```bash python autoshorts.py pick ``` Returns JSON with the next video to process. Output fields: - `path`, `name`, `size_mb`, `mtime`, `duration_s` — file metadata. - `previous_cycles_completed` — how many cycles this video has already been through (0 means first time ever). - `remaining_in_cycle` — how many other videos are still untouched in the current cycle. - `cycle_started_at` — timestamp of the current cycle's start. - `new_cycle_started` — `true` if THIS pick is the one that opened a fresh cycle (every video has been processed in the previous cycle, the loop wraps around). If `new_cycle_started` is `true`, mention it to the user briefly ("starting a new cycle — already clipped this video N times before, going for fresh moments"). It's not an error — it's the expected wrap-around. Gemini will likely pick different moments since the prompt is non-deterministic and HOT.md priors evolve over time. The pipeline does NOT hard-stop when it runs out of fresh videos. The only hard-stop case is `INPUT_FOLDER` being empty — surface that and ask the user to drop something in. If the user explicitly says "reprocess video X right now" out of cycle order, remove that entry from `state/processed.json` first, then run pick. Do NOT bypass the cycle logic by other means. ### Step 2 — Transcribe ```bash python autoshorts.py transcribe "<VIDEO_PATH>" ``` Writes `output/<video_slug>/transcript.json` with sentence segments and per-word timestamps. Whisper auto-detects language. Default model is `medium`. ### Step 3 — Analyze with Gemini 3 Flash ```bash python autoshorts.py analyze "<VIDEO_PATH>" ``` Uploads the video to Gemini Files API and asks `gemini-3-flash-preview` to return EVERY viable short-form moment (20–60s each), with timestamps snapped to word boundaries from the transcript. Output: `output/<video_slug>/clips.json`. Read this file to get the candidate list. ### Step 4 — Cut every candidate and add hook overlay For each clip in `clips.json`, run two commands: ```bash python autoshorts.py extract "<VIDEO_PATH>" \ --start <START> --end <END> \ --output "output/<slug>/clip_<ID>.mp4" python autoshorts.py hook "output/<slug>/clip_<ID>.mp4" \ --text "<HOOK_TEXT>" --duration 3 \ --output "output/<slug>/clip_<ID>_final.mp4" ``` The hook is rendered TikTok/Instagram-style: each line of text gets its own black pill (78% opacity, rounded corners) behind it, with white Impact text + black stroke on top. The pill keeps the hook legible on any background — pure white, pure black, busy screenshares — without needing to inspect the underlying frame. Positioned at the top of the frame for the first 3 seconds. Hook text comes from `clips.json` (Gemini wrote it in the video's language). Cut and hook ALL candidates upfront — the user will review the actual final files visually, not metadata. ### Step 4.5 — Visual QA of the hook (you do this yourself, no Gemini call) You are multimodal. **Use that.** Before showing the candidates to the user, verify the hook overlay actually renders cleanly on each clip. For every `clip_<ID>_final.mp4`: ```bash python autoshorts.py preview output/<slug>/clip_<ID>_final.mp4 ``` This extracts a single frame at t=1.0s (mid-hook) to `preview_clip_<ID>_final.png` next to the clip. Open it with the **Read** tool — Claude / openclaw both view PNGs directly. No Gemini call needed; the agent running the skill IS the multimodal reviewer. For each preview, evaluate: 1. Is the hook text fully visible? Any letter clipped at the left/right/top edges? 2. Is the pill background extending past the safe area (more than ~5% from any edge)? 3. Does the hook cover the speaker's face or other critical content? 4. Are accent marks / special characters (`á é í ó ú ñ ¿ ¡`) rendering correctly? 5. Is the hook overlapping with the burned-in subtitle? (Subtitles at the bottom are expected — only flag if they collide with the hook itself, which lives at the top.) 6. Any rendering glitch: garbled text, missing pill, transparency issue? **Add a "QA" column to the Step 5 table** with one of: - `✅` — clean - `⚠️ <issue>` — flag the specific problem (e.g. `⚠️ último carácter recortado`, `⚠️ pill desbordado a la derecha`) **Do NOT silently drop flagged clips** — show them to the user with the warning so they can decide. The QA pass is advisory: a "⚠️" is a hint, not a veto. If multiple clips fail in the same way (e.g. the hook is consistently overflowing), that's a signal to suggest the user shorten the hook style going forward. ### Step 5 — Present to the user Show a markdown table: | ID | Duration | Hook | Score | QA | Reason | File | |----|----------|------|-------|----|--------|------| | 1 | 38s | "..."| 9 | ✅ | ... | output/<slug>/clip_1_final.mp4 | | 2 | 27s | "..."| 7 | ⚠️ acento "ó" recortado | ... | output/<slug>/clip_2_final.mp4 | | … | … | … | … | … | … | … | **Always include the absolute file paths in the table** — openclaw uses them to attach the actual clip videos when it forwards the message to the user's messenger (Telegram / WhatsApp / etc.). Without absolute paths the user sees only metadata and cannot review the clips visually. Then ask: > **Which clip IDs do you want to publish? (e.g. `1, 3, 5`, or `none`.)** Wait for the user's reply (it will arrive via openclaw from the user's phone). **If the user replies `none`** (rejects all candidates), skip directly to Step 8 and `mark-processed` with `--clips-published 0`. This consumes the video so tomorrow's run picks the next one — otherwise the same rejected candidates would surface again. If the user wants to retry the same video later, they can manually remove its entry from `state/processed.json`. ### Step 6 — Generate platform metadata for approved clips For every approved ID, generate platform-specific copy. **This is YOUR job as Claude** — write it directly, do not call a tool. Match the language of the video. - **TikTok** (`tiktok_title`, max 90 chars): punchy hook, 1–2 emojis, hashtag mix at end of the title. Sweet spot ~70–85 chars. - **Instagram Reels** (`instagram_title`, up to 2200 chars): long-form storytelling — first line is the hook, then 2-4 short paragraphs (use `\n\n`), CTA ("Guarda esto", "Etiqueta a alguien…", "Comenta X para…"), then 20-30 hashtags mixing sizes (large/medium/niche). Sweet spot 500–800 chars total. - **YouTube Shorts** (`youtube_title`, max 100 chars but **keep ~40-60 chars** so it doesn't truncate on mobile): SEO-friendly with keywords. Description focuses on searchability, 3–5 hashtags max. - A general `title` and `description` for any platform that doesn't have its own override. **Length contract (verify before publishing):** YouTube title is the most constrained — write it shortest and most direct. TikTok and Instagram can breathe — TikTok up to ~85 chars in `tiktok_title`, Instagram captions are long-form by design. Show the generated copy back to the user and confirm before publishing. ### Step 7 — Schedule publishing Schedule one approved clip per day starting tomorrow at **10:00** in `TIMEZONE` (default `Europe/Madrid`). Each next clip += 1 day. For each approved clip: ```bash python autoshorts.py publish "output/<slug>/clip_<ID>_final.mp4" \ --platforms tiktok,instagram,youtube \ --title "<GENERAL>" \ --description "<DESCRIPTION>" \ --tiktok-title "<TIKTOK_TITLE>" \ --instagram-title "<INSTAGRAM_CAPTION>" \ --youtube-title "<YOUTUBE_TITLE>" \ --schedule "<ISO_DATE>" \ --timezone "Europe/Madrid" \ --tiktok-mode draft \ --clip-id <ID> \ --hook-text "<HOOK_TEXT>" \ --viral-score <GEMINI_SCORE> \ --reason "<GEMINI_REASON>" \ --video-source "<SOURCE_VIDEO_FILENAME>" ``` **The `--clip-id`, `--hook-text`, `--viral-score`, `--reason`, `--video-source` flags are not optional in practice** — they feed the learning loop. Without them, `learn` cannot correlate engagement metrics back to which hook patterns worked. The values come straight from `clips.json` (the Gemini output) and the source video filename. **TikTok mode**: `--tiktok-mode draft` (default) sends to the TikTok inbox via `post_mode=MEDIA_UPLOAD` so the user can finish editing in-app before publishing. Use `--tiktok-mode direct` (`DIRECT_POST`) only when the user explicitly wants immediate publishing. **Always run with `--dry-run` first** and show the user the exact request payloads. Only execute the real publish after explicit "go". ### Step 8 — Mark video as processed ```bash python autoshorts.py mark-processed "<VIDEO_PATH>" \ --clips-generated <N_CANDIDATES> \ --clips-published <N_APPROVED> ``` This appends the video's hash to `state/processed.json` so tomorrow's `pick` skips it. **Run this even if `--clips-published 0`** — a rejected video is still consumed. The only time you do NOT mark-processed is if the pipeline crashed mid-run (e.g., Gemini errored out before producing clips); in that case let the user retry the same video tomorrow. ### Step 8.5 — Reflect (optional, fast, qualitative) After publishing, you can run a quick `reflect` to capture WHY the user approved the clips they approved (no engagement metrics needed — just the approved-vs-rejected signal): ```bash python autoshorts.py reflect --window-days 30 ``` This compares recent candidates (`learnings/candidate-history.jsonl`) against approvals (`learnings/post-history.jsonl`) and asks Gemini to extract qualitative patterns ("approves hooks with concrete numbers, rejects question-form hooks"). Output goes to `learnings/runs/reflect-YYYY-MM-DD-HHMM.md`. These observations are NOT auto-promoted to HOT.md. They're notes for the user to review and curate. Run reflect occasionally — daily is overkill, weekly is fine. ### Step 9 — Final summary Print: | # | File | Duration | Hook | Schedule | Platforms | |---|------|----------|------|----------|-----------| …and the source video name with how many candidates were generated vs. published. ## Weekly learning loop (`learn`) This skill **gets smarter over time**. Engagement data from past clips (views, likes, comments, shares, saves — fetched from Upload-Post analytics) is fed back into the clip-selection prompt for future runs. ### Cadence Run `learn` **weekly**, not daily. Engagement metrics need time to mature; daily learn would chase noise. ```bash python autoshorts.py learn ``` Defaults: 7-day soak (clips younger than this are excluded), 90-day max age (older are stale), composite score = 0.6·views + 0.4·engagement_rate, top/bottom 20% as winners/losers. ### What it does 1. Reads `learnings/post-history.jsonl` (every clip we published, with its hook + Gemini score + Gemini reason + source video). 2. For each clip in the soak window, calls `GET /api/uploadposts/post-analytics/{request_id}` — same `request_id` we got back at publish time. 3. Computes a composite score per clip and picks the top 20% (winners) and bottom 20% (losers). 4. Sends winners + losers + the existing `learnings/HOT.md` to Gemini Flash with a meta-prompt asking it to produce an updated HOT.md (≤80 lines) listing patterns supported by the new evidence. 5. Writes the new HOT.md (backing up the previous one as `HOT.YYYYMMDD-HHMMSS.md.bak`). 6. Writes a full audit to `learnings/runs/learn-YYYY-MM-DD.md` so the user can see exactly which clips were called winners/losers and how the learnings changed. ### How HOT.md feeds back `cmd_analyze` automatically reads `learnings/HOT.md` (if it exists and is non-empty) and **prepends it to the Gemini prompt** as "PRIOR LEARNINGS — apply when selecting clips and writing hooks". Gemini then weighs those patterns when proposing clips and writing hooks for tomorrow's video. **You don't have to do anything to make this work** — it happens on every analyze call. ### When to run `learn` - **Manually**, on demand: `python autoshorts.py learn` - **Scheduled**, weekly via cron / openclaw: `0 9 * * 1 cd ~/Documents/skill-autoshorts && ./venv/bin/python autoshorts.py learn` - **Skip** if `post-history.jsonl` has fewer than ~10 entries — the rule of "5 winners + 5 losers minimum" will short-circuit the run with a "not enough data" note. ### Things to NOT do - Do not edit `HOT.md` by hand AND keep running `learn` — `learn` will overwrite your edits. If you want manual rules, put them in `learnings/insights/` (manual notes, not used by the pipeline). - Do not delete `post-history.jsonl` or `metrics.jsonl` — they're append-only memory. Without them every `learn` starts from zero. - Do not run `learn` more than ~once a week — Gemini will just churn the same patterns. ## Operating notes - **Always confirm** before Step 4 (heavy ffmpeg work — do NOT skip, but confirm if Gemini returned > 15 candidates — could waste time), before Step 7 (publishing is irreversible once scheduled), and after Step 6 (metadata copy). - If Gemini returns malformed JSON, the raw response is dumped to `output/<slug>/clips.raw.txt` — read it and re-prompt manually. - Hook text comes from Gemini in the video's language. Do not translate. - The Upload-Post free tier is **10 uploads/month** — one publish to 3 platforms counts as 3. Warn the user if scheduling would exceed the quota. - All clip files are absolute paths under `OUTPUT_FOLDER/<video_slug>/`. Surface them clearly so the openclaw harness can attach them when forwarding to Telegram / WhatsApp / whatever messenger channel the user has configured. - If `pick` says "all videos already processed", tell the user and stop — do not re-process. They need to drop a new video into `INPUT_FOLDER`. - The state file at `state/processed.json` is the **only** memory between runs. Never edit it programmatically except via `mark-processed`. If the user asks to "reprocess video X", the right move is to ask them to confirm, then remove the matching entry from `state/processed.json` manually. - The Whisper `medium` model (~1.5 GB) downloads on first transcribe call. Warn the user the first run will take longer — subsequent runs reuse the cached model.

path: README.md
content: # autoshorts

> Turn long videos into a daily stream of viral short-form clips for TikTok, Instagram Reels and YouTube Shorts — with a human-in-the-loop approval step you can answer from your phone.

The idea is to keep a small backlog of long videos and forward a new one to your agent every time you record. The agent (Hermes / Openclaw / whatever you're running) saves it into the input folder for you — you don't manage paths yourself, you just send the video in chat. Once a day the skill picks one video from that folder (newest unprocessed first, so the one you just sent jumps the queue), decides which moments are worth clipping, cuts them, overlays a punchy hook on top, and asks you (in the same chat channel) which ones you want to publish. You reply with the clip IDs. It schedules them on TikTok / Instagram / YouTube via [Upload-Post](https://upload-post.com).

**Input videos should already be 9:16 vertical** (1080×1920 ideally) and ready to post on social — with your subtitles already burned in if you want subs. The skill does NOT reformat, crop, or burn subtitles. It only cuts and adds a hook text overlay on top. Treat the input folder as "near-final long-form footage", not raw camera dumps.

It's the same workflow Opus Clip / Vizard sell, but you own every piece of it and you stay in the loop on what actually ships.

---

## Setup prompt

Paste into Claude Code, Codex, Hermes, Openclaw, or any agent with shell access:

> Set up https://github.com/mutonby/skill-autoshorts for me. Read README.md.

---

## Install

When an agent reads this section, here's the full install protocol it should follow:

1. Clone or `git pull` this repo into `~/Documents/skill-autoshorts/` (or whatever path the user prefers).
2. Make sure `ffmpeg` is installed (`brew install ffmpeg` on macOS, `apt install ffmpeg` on Linux). Verify with `ffmpeg -version`.
3. Create the venv and install Python deps: `python3 -m venv venv && ./venv/bin/pip install -r requirements.txt`.
4. Register `SKILL.md` with whichever agent harness is running. For Claude Code, copy or symlink it into `~/.claude/skills/autoshorts/SKILL.md`. For Hermes / Openclaw follow their skill registration docs.
5. Create `.env` from `.env.example` and ask the user to paste the values you need:
   - `GEMINI_API_KEY` — https://aistudio.google.com/apikey (free tier is enough).
   - `UPLOAD_POST_API_KEY` and `UPLOAD_POST_PROFILE` — https://app.upload-post.com → Settings → API Keys + Manage Users (free tier available, no credit card required).
   - Default `INPUT_FOLDER` and `OUTPUT_FOLDER` to `~/Documents/skill-autoshorts/input` and `.../output` unless the user says otherwise. Default `WHISPER_MODEL=medium` and `TIMEZONE=Europe/Madrid` (override if the user is in another timezone).
6. Verify the Upload-Post key works: `curl -s -H "Authorization: Apikey $UPLOAD_POST_API_KEY" https://api.upload-post.com/api/uploadposts/users` should return the user's profile and connected platforms.
7. Read `SKILL.md` end-to-end. That's the canonical daily workflow — visual QA, weekly `learn`, all the operational rules.

**After install, do nothing else.** Don't transcribe, don't call Gemini, don't publish. Tell the user everything is ready, summarize what's connected on Upload-Post, and wait. The user will forward videos to you in chat going forward — your job is to save each one into `INPUT_FOLDER` and invoke the skill (`/autoshorts` or equivalent) when they ask.

### Notes for the user during install

- The first transcribe call downloads Whisper `medium` (~1.5 GB). Subsequent runs are instant.
- Upload-Post platform requirements: **TikTok** any account works · **Instagram** must be a Business or Creator account linked to a Facebook Page · **YouTube** any Google account · LinkedIn / Reddit / Threads / X also supported.
- The `UPLOAD_POST_PROFILE` value is the **profile name** you create in Upload-Post → Manage Users, NOT your social handle.

---

## The interesting part: how the clips are picked

Most "auto-clip" tools either (a) work from text only and miss the visual cues that make a moment funny / tense / shareable, or (b) work from video only and produce ragged cuts that chop people mid-word. We sidestep both.

```text
┌─────────────────┐
│  long video.mp4 │
└────────┬────────┘
         │
         ├──────────────► Whisper (medium, local)
         │                ─ word-level timestamps
         │                ─ language auto-detect
         │                ─ outputs transcript.json
         │
         └──────────────► Gemini 3 Flash (multimodal, cloud)
                          ─ receives the FULL VIDEO + the transcript
                          ─ sees laughter, gestures, scene changes
                          ─ MUST snap clip start/end to word boundaries from the transcript
                          ─ outputs clips.json with {start, end, hook_text, reason, score}

                                          │
                                          ▼
                          ffmpeg cut + Pillow hook overlay
                                          │
                                          ▼
                                 Upload-Post API
                                  ↳ TikTok (draft)
                                  ↳ Instagram Reels
                                  ↳ YouTube Shorts
```

**Why this combo works:**

1. **Whisper is the clock.** Word-level timestamps mean every cut starts and ends on a clean word boundary. No mid-syllable chops, no half-breaths.
2. **Gemini Flash is the editor.** It's multimodal — we send the actual video file via the Files API along with the transcript. It can see a punchline land, hear laughter, notice a scene change, react to a chart on screen. Crucially, the prompt forces it to use timestamps from the Whisper transcript, so it can't hallucinate "20.5s" and miss by a syllable.
3. **The pipeline is human-gated.** The model proposes; you dispose. Every candidate is cut and rendered before you see it, so you review the actual final video, not a description. You answer from your phone.

---

## Usage

### Inside an agent harness (recommended — Hermes / Openclaw / similar)

This is what the skill is built for. An agent harness on a VPS runs the daily cron, pings you on your messenger ("here are today's candidates, which do I publish?"), captures your reply, and closes the loop. You drop new videos in chat whenever you record; you get a daily push of clip candidates; you reply with IDs from your phone. Hands-off after install.

The skill itself doesn't talk to Telegram / WhatsApp / etc. — the harness does. The skill just runs the pipeline and surfaces a candidates table; the harness forwards it to your messenger and pipes your reply back.

For invocation, the harness either fires `/autoshorts` on its daily cron, or invokes the equivalent in its own skill system. SKILL.md is the contract.

### As a standalone CLI

```bash
source venv/bin/activate

# 1. Pick the next unprocessed video
python autoshorts.py pick

# 2. Transcribe (writes output/<slug>/transcript.json)
python autoshorts.py transcribe input/your-video.mp4

# 3. Analyze with Gemini (writes output/<slug>/clips.json)
python autoshorts.py analyze input/your-video.mp4

# 4. For each clip in clips.json:
python autoshorts.py extract input/your-video.mp4 \
    --start 12.34 --end 45.67 \
    --output output/your-video/clip_1.mp4

python autoshorts.py hook output/your-video/clip_1.mp4 \
    --text "Tu hook aquí" --duration 3 \
    --output output/your-video/clip_1_final.mp4

# 5. Publish (TikTok defaults to draft / MEDIA_UPLOAD)
python autoshorts.py publish output/your-video/clip_1_final.mp4 \
    --platforms tiktok,instagram,youtube \
    --title "general title" \
    --description "general description" \
    --tiktok-title "TikTok-specific (max 90 chars, can have emojis + hashtags)" \
    --instagram-title "Instagram caption (long-form, 500-800 chars + 20-30 hashtags)" \
    --youtube-title "YouTube title (~40-60 chars, SEO-friendly)" \
    --schedule "2026-05-01T10:00:00" \
    --timezone "Europe/Madrid" \
    --tiktok-mode draft

# 6. Mark the source video as consumed (so tomorrow's pick skips it)
python autoshorts.py mark-processed input/your-video.mp4 \
    --clips-generated 5 --clips-published 3
```

### As a daily loop

The whole thing is designed to run forever, one video per day:

- You drop new long videos into `INPUT_FOLDER` whenever you have one.
- A cron / systemd / openclaw schedule fires `/autoshorts` daily.
- `pick` always chooses the **newest unprocessed** video. Fresh material jumps the queue. Old unprocessed videos still drain over time.
- `state/processed.json` (sha256-keyed) is the only memory between runs — it's what prevents the same video being clipped twice.
- If you reject ALL candidates ("none"), the video is still marked consumed. To retry, manually remove its entry from `state/processed.json`.

---

## How it learns

The pipeline gets smarter with every clip you publish. Engagement data flows back from Upload-Post analytics into the Gemini prompt that selects tomorrow's clips.

```text
                          publish ─────► Upload-Post
                            │                │
                            ▼                ▼
                  post-history.jsonl   real platform metrics
                  (clip → request_id,   (views, likes, comments,
                   hook, score, …)      shares, saves)
                            │                │
                            └───────┬────────┘
                                    │
                                    ▼ (weekly)
                              learn subcommand
                            ─ z-score per platform
                            ─ composite = 0.6·views + 0.4·engagement_rate
                            ─ top 20% = winners
                            ─ bottom 20% = losers
                                    │
                                    ▼
                              Gemini Flash
                            "Here are winners and losers,
                             plus the current HOT.md.
                             Output an updated HOT.md
                             (≤80 lines of patterns)."
                                    │
                                    ▼
                            learnings/HOT.md
                                    │
                                    ▼ (every analyze call, automatically)
                            prepended to Gemini's analyze prompt
                                    │
                                    ▼
                            tomorrow's clips reflect what worked
```

### Three CLI commands drive the loop

| Command | Cadence | What it does |
|---|---|---|
| `publish` (with `--clip-id --hook-text --viral-score --reason --video-source`) | every approved clip | logs the clip's full context to `learnings/post-history.jsonl` so we can correlate it with metrics later |
| `learn` | weekly | pulls fresh analytics, finds winners/losers, asks Gemini to refresh `HOT.md` |
| `reflect` (optional) | when you want | quick qualitative pass — compares which candidates you APPROVED vs REJECTED, no metrics needed |

### Composite metric

`learn` ranks clips by a weighted z-score:

```
composite = 0.6 × z(total_views) + 0.4 × z(engagement_rate)

where engagement_rate = (likes + comments + shares + saves) / total_views
```

Both weights are flags (`--weight-views`, `--weight-engagement`) — bump engagement higher if you care more about quality than reach, lower if you're optimizing pure volume.

### Soak window

`learn --soak-days 7` (default): clips younger than 7 days are excluded — engagement metrics need time to mature, daily learning would chase noise. Older than 90 days = stale and ignored too.

If you have fewer than ~5 winners + 5 losers, `learn` skips the synthesis and writes a "not enough data" note to `learnings/runs/learn-YYYY-MM-DD.md`. Just keep publishing.

### Auditability

Every `learn` run writes a full audit to `learnings/runs/learn-YYYY-MM-DD.md`: which clips were called winners, with their scores, the previous HOT.md, and the new HOT.md side-by-side. The previous HOT.md is also backed up as `HOT.YYYYMMDD-HHMMSS.md.bak`. You can roll back if Gemini synthesizes garbage.

### Reflect (no-metrics qualitative pass)

`reflect --window-days 30` is a faster pass that doesn't wait for engagement data. It compares the clips Gemini OFFERED against the ones you APPROVED and asks Gemini to extract qualitative patterns ("approves hooks with concrete numbers, rejects question-form hooks"). Output goes to `learnings/runs/reflect-...md` and is **not** auto-promoted to HOT.md — it's notes for you to read and curate.

### Why we don't auto-promote everything

`learn` overwrites `HOT.md` based on metrics only — that's safe because the data is real. `reflect` is observational and could lock in your past biases ("I always reject question hooks") rather than what actually performs. So reflect output stays in `runs/` for human review.

---

## File layout

```
skill-autoshorts/
├── README.md                  ← you are here
├── autoshorts.py              ← CLI: pick / transcribe / analyze / extract / hook / publish / mark-processed
├── requirements.txt
├── .env                       ← secrets (gitignored)
├── .env.example
├── input/                     ← drop long videos here
├── output/
│   └── <video_slug>/
│       ├── transcript.json    ← Whisper output (segments + word timestamps)
│       ├── clips.json         ← Gemini's clip selections
│       ├── clip_1.mp4         ← raw cut
│       ├── clip_1_final.mp4   ← cut + hook overlay
│       └── …
├── state/
│   └── processed.json         ← sha256s of videos already processed
└── learnings/
    ├── HOT.md                 ← auto-managed by `learn`, prepended to every analyze prompt
    ├── post-history.jsonl     ← every clip we published (request_id, hook, score, …)
    ├── candidate-history.jsonl ← every candidate Gemini offered (so reflect can compare)
    ├── metrics.jsonl          ← analytics snapshots from Upload-Post
    ├── insights/              ← MANUAL notes (not used by the pipeline)
    └── runs/
        ├── learn-YYYY-MM-DD.md
        └── reflect-YYYY-MM-DD-HHMM.md
```

---

## Per-platform copy guidance

The publish helper takes one general `--title` / `--description` plus per-platform overrides. Lengths are asymmetric:

| Platform | Field | Practical sweet spot | Hard limit | Style |
|---|---|---|---|---|
| YouTube Shorts | `--youtube-title` | **40–60 chars** | 100 | Short, SEO-friendly with keywords. Truncates on mobile if longer. |
| TikTok | `--tiktok-title` | 70–85 chars | 90 | Punchy, 1–2 emojis, hashtags at the end |
| Instagram Reels | `--instagram-title` | 500–800 chars | 2200 | Long-form storytelling: hook line, 2–4 short paragraphs, CTA, then 20–30 hashtags |

Don't reuse the same string across platforms. YouTube wants compression; Instagram wants depth.

---

## Why these tech choices

- **Whisper `medium`** — the sweet spot for accuracy vs. speed on consumer hardware (CPU `int8`). `small` is twice as fast but loses on technical vocabulary; `large-v3` is markedly better but ~3× slower.
- **Gemini 3 Flash Preview (multimodal)** — has a free tier, accepts video via the Files API, returns strict JSON via `response_mime_type=application/json`, and is cheap enough to run daily. Crucially, it can *watch* the video, not just read its transcript.
- **Pillow + ffmpeg overlay** for the hook — the alternative (ffmpeg's `drawtext` filter) requires `libfreetype` which is missing from many Homebrew ffmpeg builds. Rendering the hook to a transparent PNG with PIL and compositing with the always-available `overlay` filter is more portable and gives nicer text rendering (anti-aliasing, auto word-wrap, multi-line layout). The hook itself uses a TikTok-style **black pill behind each line of text** (78% opacity, rounded corners) so it stays legible regardless of what's underneath — pure white frames, pure black frames, or busy screenshares all work without per-frame analysis.
- **Upload-Post** — one API for ~10 platforms, OAuth handled in their dashboard, supports scheduling and platform-specific titles. The free tier (10 uploads/month) is enough to validate the pipeline; paid plans for production volume.

## Limitations / things to know

- **Quota**: Upload-Post free tier = 10 uploads/month, where one publish to 3 platforms counts as 3. Paid plans available.
- **TikTok draft mode (default)**: with `--tiktok-mode draft`, clips land in your TikTok inbox (`post_mode=MEDIA_UPLOAD`) — you finish editing in the TikTok app before publishing. Use `--tiktok-mode direct` if you want immediate publication.
- **Whisper first-run download**: ~1.5 GB on first transcribe; cached afterwards.
- **Gemini Files API processing**: a 9-minute video takes ~30–60s of processing on Google's side before it's queryable. The script polls and waits.
- **Rate-limiting**: the daily-loop design is partly to stay friendly with TikTok / Instagram limits — bulk-publishing a backlog at once is more likely to be flagged than 1/day.
- **Newest-first prioritization**: if you keep dropping new videos every day, older ones may sit in the queue indefinitely. That's intentional (fresh content > old backlog) but if you want strict FIFO, swap the sort in `cmd_pick`.


path: autoshorts.py
content: #!/usr/bin/env python3
"""autoshorts CLI — viral clip pipeline.

Subcommands:
    pick                  pick the next video to process from INPUT_FOLDER
    transcribe <video>    Whisper transcription with word timestamps
    analyze <video>       Gemini 3 Flash multimodal clip selection
    extract <video>       FFmpeg cut a single clip
    hook <video>          FFmpeg hook-text overlay
    preview <video>       extract a single frame for visual QA by the agent running the skill
    publish <video>       upload to TikTok/Instagram/YouTube via Upload-Post
    mark-processed <video>
    list-processed
    learn                 weekly: pull analytics, find winners/losers, refresh HOT.md
    reflect               post-publish: extract qualitative patterns from approved vs rejected hooks
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

from dotenv import load_dotenv

ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")

INPUT_FOLDER = Path(os.getenv("INPUT_FOLDER", ROOT / "input")).expanduser()
OUTPUT_FOLDER = Path(os.getenv("OUTPUT_FOLDER", ROOT / "output")).expanduser()
STATE_FOLDER = ROOT / "state"
STATE_FILE = STATE_FOLDER / "processed.json"

LEARNINGS_FOLDER = ROOT / "learnings"
HOT_FILE = LEARNINGS_FOLDER / "HOT.md"
POST_HISTORY = LEARNINGS_FOLDER / "post-history.jsonl"
CANDIDATE_HISTORY = LEARNINGS_FOLDER / "candidate-history.jsonl"
METRICS_FILE = LEARNINGS_FOLDER / "metrics.jsonl"
RUNS_FOLDER = LEARNINGS_FOLDER / "runs"

VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".m4v", ".webm"}
GEMINI_MODEL = "gemini-3-flash-preview"
UPLOAD_POST_BASE = "https://api.upload-post.com/api"


def append_jsonl(path: Path, record: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")


def read_jsonl(path: Path) -> list[dict]:
    if not path.exists():
        return []
    out = []
    with path.open(encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                out.append(json.loads(line))
    return out


# ---------- helpers ----------

def sha256_of(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def load_state() -> dict:
    if not STATE_FILE.exists():
        return {"cycle_started_at": None, "processed": []}
    state = json.loads(STATE_FILE.read_text())
    state.setdefault("cycle_started_at", None)
    state.setdefault("processed", [])
    # Backfill schema: old records may only have processed_at, not last_processed_at.
    for rec in state["processed"]:
        if "last_processed_at" not in rec:
            rec["last_processed_at"] = rec.get("processed_at")
        if "first_processed_at" not in rec:
            rec["first_processed_at"] = rec.get("processed_at")
        if "cycles_count" not in rec:
            rec["cycles_count"] = 1
    return state


def save_state(state: dict) -> None:
    STATE_FOLDER.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))


def video_slug(video: Path) -> str:
    return video.stem.replace(" ", "_")


def video_output_dir(video: Path) -> Path:
    d = OUTPUT_FOLDER / video_slug(video)
    d.mkdir(parents=True, exist_ok=True)
    return d


def run_ffmpeg(args: list[str]) -> None:
    cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *args]
    res = subprocess.run(cmd, capture_output=True, text=True)
    if res.returncode != 0:
        sys.stderr.write(res.stderr)
        raise SystemExit(f"ffmpeg failed: {' '.join(cmd)}")


def ffprobe_duration(video: Path) -> float:
    res = subprocess.run(
        [
            "ffprobe", "-v", "error",
            "-show_entries", "format=duration",
            "-of", "default=noprint_wrappers=1:nokey=1",
            str(video),
        ],
        capture_output=True, text=True, check=True,
    )
    return float(res.stdout.strip())


def ffprobe_dimensions(video: Path) -> tuple[int, int]:
    res = subprocess.run(
        [
            "ffprobe", "-v", "error",
            "-select_streams", "v:0",
            "-show_entries", "stream=width,height",
            "-of", "csv=p=0:s=x",
            str(video),
        ],
        capture_output=True, text=True, check=True,
    )
    w, h = res.stdout.strip().split("x")
    return int(w), int(h)


# ---------- pick ----------

def cmd_pick(_: argparse.Namespace) -> None:
    """Print the path of the next video to process.

    Cycle strategy: each video is picked at most once per cycle. When every
    video in INPUT_FOLDER has been processed in the current cycle, a new cycle
    starts and they all become available again. Newest unprocessed-this-cycle
    wins, so freshly added videos still jump the queue.
    """
    if not INPUT_FOLDER.exists():
        raise SystemExit(f"INPUT_FOLDER not found: {INPUT_FOLDER}")

    candidates = [
        p for p in INPUT_FOLDER.iterdir()
        if p.is_file() and p.suffix.lower() in VIDEO_EXTS
    ]
    if not candidates:
        raise SystemExit(f"no videos in {INPUT_FOLDER}")

    state = load_state()
    cycle_start = state.get("cycle_started_at")
    by_hash = {rec["hash"]: rec for rec in state["processed"]}

    def is_available(p: Path) -> bool:
        rec = by_hash.get(sha256_of(p))
        if rec is None:
            return True  # never processed
        last = rec.get("last_processed_at")
        if cycle_start is None or last is None:
            return False  # processed before cycle tracking existed → treat as taken
        return last < cycle_start

    available = [p for p in candidates if is_available(p)]
    new_cycle = False

    if not available:
        # All videos processed in current cycle → start a new one.
        state["cycle_started_at"] = datetime.now().isoformat(timespec="seconds")
        save_state(state)
        cycle_start = state["cycle_started_at"]
        available = list(candidates)
        new_cycle = True

    available.sort(key=lambda p: p.stat().st_mtime, reverse=True)
    chosen = available[0]
    rec = by_hash.get(sha256_of(chosen)) or {}

    print(json.dumps({
        "path": str(chosen),
        "name": chosen.name,
        "size_mb": round(chosen.stat().st_size / 1_000_000, 1),
        "mtime": datetime.fromtimestamp(chosen.stat().st_mtime).isoformat(),
        "duration_s": round(ffprobe_duration(chosen), 1),
        "previous_cycles_completed": rec.get("cycles_count", 0),
        "remaining_in_cycle": len(available) - 1,
        "cycle_started_at": cycle_start,
        "new_cycle_started": new_cycle,
    }, indent=2))


# ---------- transcribe ----------

def cmd_transcribe(args: argparse.Namespace) -> None:
    from faster_whisper import WhisperModel

    video = Path(args.video).resolve()
    out_dir = video_output_dir(video)
    out_path = Path(args.output) if args.output else out_dir / "transcript.json"

    model_name = args.model or os.getenv("WHISPER_MODEL", "medium")
    print(f"[transcribe] loading whisper {model_name}…", file=sys.stderr)
    model = WhisperModel(model_name, device="cpu", compute_type="int8")

    print(f"[transcribe] running on {video.name}…", file=sys.stderr)
    segments_iter, info = model.transcribe(
        str(video),
        word_timestamps=True,
        vad_filter=True,
    )

    segments = []
    for seg in segments_iter:
        words = []
        for w in (seg.words or []):
            words.append({"s": round(w.start, 3), "e": round(w.end, 3), "t": w.word.strip()})
        segments.append({
            "start": round(seg.start, 3),
            "end": round(seg.end, 3),
            "text": seg.text.strip(),
            "words": words,
        })

    payload = {
        "video": video.name,
        "language": info.language,
        "language_probability": round(info.language_probability, 3),
        "duration": round(info.duration, 3),
        "segments": segments,
    }
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
    print(f"[transcribe] {len(segments)} segments → {out_path}", file=sys.stderr)
    print(str(out_path))


# ---------- analyze ----------

ANALYZE_PROMPT = """You are an expert short-form viral video editor. You are given:
1. A long video file.
2. A transcript with sentence-level segments and per-word timestamps.

TASK
Find ALL moments suitable for TikTok / Instagram Reels / YouTube Shorts. Be generous — return every viable candidate, do not artificially limit. Quality over filler, but do not stop at 3 if there are 8 good ones.

REQUIREMENTS
- Duration 20-60 seconds.
- Self-contained: makes sense without prior context. Avoid moments that reference unseen context ("as I said earlier", "going back to that").
- Strong hook in the first 2 seconds: surprise, controversy, emotional peak, punchline, contrarian take, actionable tip, story climax.
- `start` MUST equal an existing word's start time in the transcript and `end` MUST equal an existing word's end time. Do NOT invent timestamps. Snap to word boundaries.
- Use multimodal cues from the video itself: laughter, gestures, scene changes, energy peaks, reactions.
- If two candidates overlap, return only the stronger one.

FOR EACH CLIP RETURN
- id (sequential int starting at 1)
- start (float seconds, snapped to a word's start_time)
- end (float seconds, snapped to a word's end_time)
- hook_text (max 8 words, attention-grabbing, SAME LANGUAGE as the video)
- reason (1 short sentence on why this is viral)
- viral_score (integer 1-10, 10 = certain hit)

OUTPUT
Return STRICT JSON only, no commentary, no markdown:
{"language": "<iso lang>", "clips": [{"id": 1, "start": 12.34, "end": 45.67, "hook_text": "...", "reason": "...", "viral_score": 8}, ...]}
"""

PRIORS_HEADER = """
PRIOR LEARNINGS FROM THIS CREATOR'S PAST CLIPS
The patterns below are derived from real engagement data on this creator's previously published clips. Apply them when selecting moments AND when writing hooks. They override the generic guidance above when they conflict.

"""

PRIORS_FOOTER = "\n\n--- end of prior learnings ---\n\nTRANSCRIPT\n"
TRANSCRIPT_HEADER = "\nTRANSCRIPT\n"


def cmd_analyze(args: argparse.Namespace) -> None:
    from google import genai
    from google.genai import types

    [REDACTED_SECRET]"GEMINI_API_KEY")
    if not api_key:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    video = Path(args.video).resolve()
    out_dir = video_output_dir(video)
    transcript_path = Path(args.transcript) if args.transcript else out_dir / "transcript.json"
    out_path = Path(args.output) if args.output else out_dir / "clips.json"

    if not transcript_path.exists():
        raise SystemExit(f"transcript not found: {transcript_path}")

    transcript = transcript_path.read_text()

    # Inject prior learnings if HOT.md exists with content.
    priors_block = ""
    if HOT_FILE.exists():
        hot = HOT_FILE.read_text().strip()
        if hot:
            priors_block = PRIORS_HEADER + hot + PRIORS_FOOTER
            print(f"[analyze] injecting {len(hot)} chars of priors from HOT.md", file=sys.stderr)

    if priors_block:
        prompt = ANALYZE_PROMPT + priors_block + transcript
    else:
        prompt = ANALYZE_PROMPT + TRANSCRIPT_HEADER + transcript

    client = genai.Client([REDACTED_SECRET])
    print(f"[analyze] uploading {video.name} to Gemini Files API…", file=sys.stderr)
    uploaded = client.files.upload(file=str(video))

    while uploaded.state.name == "PROCESSING":
        time.sleep(3)
        uploaded = client.files.get(name=uploaded.name)
        print(f"[analyze] file state: {uploaded.state.name}", file=sys.stderr)

    if uploaded.state.name != "ACTIVE":
        raise SystemExit(f"video upload failed: {uploaded.state.name}")

    print(f"[analyze] calling {GEMINI_MODEL}…", file=sys.stderr)
    response = client.models.generate_content(
        model=GEMINI_MODEL,
        contents=[uploaded, prompt],
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
        ),
    )

    raw = response.text.strip()
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        out_path.with_suffix(".raw.txt").write_text(raw)
        raise SystemExit(f"Gemini returned non-JSON, dumped to {out_path.with_suffix('.raw.txt')}")

    out_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
    n = len(data.get("clips", []))
    print(f"[analyze] {n} clip candidates → {out_path}", file=sys.stderr)

    # Log every candidate to candidate-history so reflect can compare approved vs rejected.
    now_iso = datetime.now().isoformat(timespec="seconds")
    for c in data.get("clips", []):
        append_jsonl(CANDIDATE_HISTORY, {
            "video_source": video.name,
            "analyzed_at": now_iso,
            "clip_id": c.get("id"),
            "start": c.get("start"),
            "end": c.get("end"),
            "duration_s": (c.get("end", 0) - c.get("start", 0)) if c.get("end") and c.get("start") is not None else None,
            "hook_text": c.get("hook_text"),
            "viral_score_gemini": c.get("viral_score"),
            "reason_gemini": c.get("reason"),
            "language": data.get("language"),
            "had_priors": bool(priors_block),
        })

    print(str(out_path))


# ---------- extract ----------

def cmd_extract(args: argparse.Namespace) -> None:
    video = Path(args.video).resolve()
    start = float(args.start)
    end = float(args.end)
    out = Path(args.output).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)

    # Re-encode for frame-accurate cuts (copy mode would snap to keyframes).
    run_ffmpeg([
        "-ss", f"{start:.3f}",
        "-to", f"{end:.3f}",
        "-i", str(video),
        "-c:v", "libx264", "-preset", "fast", "-crf", "20",
        "-c:a", "aac", "-b:a", "160k",
        "-movflags", "+faststart",
        str(out),
    ])
    print(str(out))


# ---------- hook overlay ----------

DEFAULT_FONT = "/System/Library/Fonts/Supplemental/Impact.ttf"


def render_hook_png(text: str, png_path: Path, video_w: int, font_path: str,
                    font_size: int = 72, max_ratio: float = 0.85) -> None:
    """Render hook text to a transparent PNG sized to the video width.

    Auto-wraps long text. Each line gets a semi-opaque black "pill" behind it
    (TikTok/Instagram style) plus white fill + black stroke on the text itself,
    so the hook stays legible on any background — including pure black or
    pure white frames where stroke alone would fail.

    Avoids ffmpeg's drawtext filter (which requires libfreetype, often missing).
    """
    from PIL import Image, ImageDraw, ImageFont

    font = ImageFont.truetype(font_path, font_size)
    max_w = int(video_w * max_ratio)

    # Greedy word wrap on the available width minus the pill horizontal padding.
    plate_pad_x = 26
    plate_pad_y = 10
    line_gap = 10  # vertical gap between consecutive pills
    text_max_w = max_w - plate_pad_x * 2

    words = text.split()
    lines: list[str] = []
    current: list[str] = []
    for w in words:
        candidate = " ".join(current + [w])
        bbox = font.getbbox(candidate)
        if (bbox[2] - bbox[0]) > text_max_w and current:
            lines.append(" ".join(current))
            current = [w]
        else:
            current.append(w)
    if current:
        lines.append(" ".join(current))

    # Use font ascent/descent for stable line geometry.
    ascent, descent = font.getmetrics()
    line_h = ascent + descent
    pill_h = line_h + plate_pad_y * 2
    canvas_pad = 12  # outer padding so stroke isn't clipped
    total_h = pill_h * len(lines) + line_gap * (len(lines) - 1) + canvas_pad * 2

    img = Image.new("RGBA", (video_w, total_h), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)

    plate_color = (0, 0, 0, 200)  # ~78% opacity black

    for i, line in enumerate(lines):
        bbox = font.getbbox(line)
        line_w = bbox[2] - bbox[0]

        text_x = (video_w - line_w) // 2
        pill_top = canvas_pad + i * (pill_h + line_gap)
        text_y = pill_top + plate_pad_y

        plate_x0 = text_x - plate_pad_x
        plate_y0 = pill_top
        plate_x1 = text_x + line_w + plate_pad_x
        plate_y1 = pill_top + pill_h
        radius = min(24, pill_h // 3)
        draw.rounded_rectangle(
            (plate_x0, plate_y0, plate_x1, plate_y1),
            radius=radius, fill=plate_color,
        )

        draw.text(
            (text_x, text_y), line, font=font,
            fill=(255, 255, 255, 255),
            stroke_width=3, stroke_fill=(0, 0, 0, 255),
        )

    img.save(png_path, "PNG")


def cmd_hook(args: argparse.Namespace) -> None:
    video = Path(args.video).resolve()
    out = Path(args.output).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)

    font = args.font or DEFAULT_FONT
    if not Path(font).exists():
        raise SystemExit(f"font not found: {font}")

    duration = float(args.duration)
    video_w, video_h = ffprobe_dimensions(video)

    png_path = out.with_suffix(".hook.png")
    render_hook_png(args.text, png_path, video_w, font)

    overlay = (
        f"[0:v][1:v]overlay=0:H*0.08:enable='lte(t,{duration})'"
    )

    run_ffmpeg([
        "-i", str(video),
        "-i", str(png_path),
        "-filter_complex", overlay,
        "-c:v", "libx264", "-preset", "fast", "-crf", "20",
        "-c:a", "copy",
        "-movflags", "+faststart",
        str(out),
    ])
    png_path.unlink(missing_ok=True)
    print(str(out))


# ---------- preview (frame extraction for agent-side QA) ----------

def cmd_preview(args: argparse.Namespace) -> None:
    """Extract one frame from a final clip so the agent running the skill
    (Claude / openclaw) can view it with their multimodal vision and decide
    whether the hook overlay looks correct. We do NOT call Gemini here —
    the agent reviews the PNG directly.
    """
    clip = Path(args.video).resolve()
    if not clip.exists():
        raise SystemExit(f"clip not found: {clip}")

    if args.output:
        out = Path(args.output).resolve()
    else:
        out = clip.with_name(f"preview_{clip.stem}.png")
    out.parent.mkdir(parents=True, exist_ok=True)

    run_ffmpeg([
        "-ss", f"{args.at_time:.3f}",
        "-i", str(clip),
        "-frames:v", "1",
        "-q:v", "2",
        str(out),
    ])
    print(str(out))


# ---------- publish ----------

def cmd_publish(args: argparse.Namespace) -> None:
    import requests

    [REDACTED_SECRET]"UPLOAD_POST_API_KEY")
    profile = os.getenv("UPLOAD_POST_PROFILE")
    if not api_key or not profile:
        raise SystemExit("UPLOAD_POST_API_KEY or UPLOAD_POST_PROFILE missing in .env")

    video = Path(args.video).resolve()
    if not video.exists():
        raise SystemExit(f"video not found: {video}")

    platforms = [p.strip() for p in args.platforms.split(",") if p.strip()]
    timezone = args.timezone or os.getenv("TIMEZONE", "Europe/Madrid")

    data = [
        ("user", profile),
        ("title", args.title or ""),
        ("description", args.description or ""),
    ]
    for p in platforms:
        data.append(("platform[]", p))
    if args.tiktok_title:
        data.append(("tiktok_title", args.tiktok_title))
    if args.instagram_title:
        data.append(("instagram_title", args.instagram_title))
    if args.youtube_title:
        data.append(("youtube_title", args.youtube_title))
    if "tiktok" in platforms:
        post_mode = "MEDIA_UPLOAD" if args.tiktok_mode == "draft" else "DIRECT_POST"
        data.append(("post_mode", post_mode))
        if args.tiktok_privacy:
            data.append(("privacy_level", args.tiktok_privacy))
    if args.schedule:
        data.append(("scheduled_date", args.schedule))
        data.append(("timezone", timezone))
    elif args.add_to_queue:
        data.append(("add_to_queue", "true"))

    if args.dry_run:
        print(json.dumps({
            "DRY_RUN": True,
            "endpoint": f"{UPLOAD_POST_BASE}/upload",
            "video": str(video),
            "fields": [(k, v) for k, v in data],
        }, indent=2, ensure_ascii=False))
        return

    with video.open("rb") as fh:
        files = {"video": (video.name, fh, "video/mp4")}
        res = requests.post(
            f"{UPLOAD_POST_BASE}/upload",
            headers={"Authorization": f"Apikey {api_key}"},
            data=data,
            files=files,
            timeout=600,
        )

    if res.status_code >= 400:
        sys.stderr.write(res.text + "\n")
        raise SystemExit(f"upload-post HTTP {res.status_code}")

    body = res.json()

    # Append to post-history.jsonl so cmd_learn can correlate metrics → clip context.
    request_id = body.get("request_id")
    if request_id:
        append_jsonl(POST_HISTORY, {
            "request_id": request_id,
            "job_id": body.get("job_id"),
            "video_source": args.video_source,
            "clip_id": args.clip_id,
            "hook_text": args.hook_text,
            "viral_score_gemini": args.viral_score,
            "reason_gemini": args.reason,
            "duration_s": ffprobe_duration(video) if video.exists() else None,
            "platforms": platforms,
            "tiktok_title": args.tiktok_title,
            "instagram_title": args.instagram_title,
            "youtube_title": args.youtube_title,
            "general_title": args.title,
            "tiktok_mode": args.tiktok_mode if "tiktok" in platforms else None,
            "scheduled_date": args.schedule,
            "published_at": datetime.now().isoformat(timespec="seconds"),
            "clip_file": str(video),
        })

    print(json.dumps(body, indent=2, ensure_ascii=False))


# ---------- state ----------

def cmd_mark_processed(args: argparse.Namespace) -> None:
    video = Path(args.video).resolve()
    state = load_state()
    digest = sha256_of(video)
    now = datetime.now().isoformat(timespec="seconds")

    if state.get("cycle_started_at") is None:
        state["cycle_started_at"] = now

    existing = next((r for r in state["processed"] if r["hash"] == digest), None)
    if existing:
        existing["last_processed_at"] = now
        existing["cycles_count"] = existing.get("cycles_count", 1) + 1
        existing.setdefault("history", []).append({
            "processed_at": now,
            "clips_generated": args.clips_generated,
            "clips_published": args.clips_published,
        })
        existing["clips_generated"] = args.clips_generated
        existing["clips_published"] = args.clips_published
        cycle_n = existing["cycles_count"]
    else:
        rec = {
            "path": str(video),
            "name": video.name,
            "hash": digest,
            "first_processed_at": now,
            "last_processed_at": now,
            "cycles_count": 1,
            "clips_generated": args.clips_generated,
            "clips_published": args.clips_published,
            "history": [{
                "processed_at": now,
                "clips_generated": args.clips_generated,
                "clips_published": args.clips_published,
            }],
        }
        state["processed"].append(rec)
        cycle_n = 1
    save_state(state)
    print(f"marked: {video.name} (cycle #{cycle_n})")


def cmd_list_processed(_: argparse.Namespace) -> None:
    state = load_state()
    print(json.dumps(state, indent=2, ensure_ascii=False))


# ---------- learn ----------

LEARN_META_PROMPT = """You are a senior short-form content strategist. You have access to engagement data from this creator's previously published clips.

Below you will see:
1. The CURRENT HOT.md (or empty if none yet) — the patterns we currently believe in.
2. A list of WINNERS — clips that performed in the top 20% by composite score (0.6·views + 0.4·engagement_rate).
3. A list of LOSERS — clips that performed in the bottom 20%.

For each clip you see: the hook_text on screen, the duration, the original Gemini viral_score, the per-platform metrics, and the original reason Gemini gave for picking it.

YOUR JOB
Produce an updated HOT.md containing only patterns supported by the new evidence, merged with the existing HOT.md. Keep what is still corroborated; remove what the new data contradicts; add new patterns that show up in the winners and are absent from the losers.

CONSTRAINTS
- Maximum 80 lines of markdown.
- Each bullet is a single, actionable, falsifiable rule. Avoid platitudes ("be engaging").
- Cite sample sizes when meaningful: "(seen in 4/5 winners, 0/5 losers)".
- Do not include raw post titles or PII.
- If the evidence is weak (fewer than 5 winners or 5 losers), output the existing HOT.md with at most a single appended bullet noting "evidence still thin, X clips analyzed so far".
- Write in the language that dominates the creator's hooks. If the hooks are in Spanish, write the rules in Spanish. If mixed, prefer Spanish.

OUTPUT
Return ONLY the updated HOT.md content as plain markdown. No preamble, no JSON wrapper, no closing remarks. Just the file body."""


def _post_metrics(platforms: dict) -> dict:
    """Sum views/engagement across all platforms in a post-analytics response."""
    total_views = 0
    total_engagement = 0
    per_platform = {}
    for platform, data in (platforms or {}).items():
        m = (data or {}).get("post_metrics") or {}
        views = int(m.get("views") or m.get("impressions") or m.get("reach") or 0)
        likes = int(m.get("likes") or 0)
        comments = int(m.get("comments") or 0)
        shares = int(m.get("shares") or 0)
        saves = int(m.get("saves") or 0)
        eng = likes + comments + shares + saves
        total_views += views
        total_engagement += eng
        per_platform[platform] = {
            "views": views, "likes": likes, "comments": comments,
            "shares": shares, "saves": saves, "engagement": eng,
        }
    eng_rate = total_engagement / total_views if total_views else 0.0
    return {
        "total_views": total_views,
        "total_engagement": total_engagement,
        "engagement_rate": eng_rate,
        "per_platform": per_platform,
    }


def _zscore(values: list[float]) -> list[float]:
    if not values:
        return []
    n = len(values)
    mean = sum(values) / n
    var = sum((v - mean) ** 2 for v in values) / n
    sd = var ** 0.5
    if sd == 0:
        return [0.0] * n
    return [(v - mean) / sd for v in values]


def cmd_learn(args: argparse.Namespace) -> None:
    import requests
    from google import genai
    from google.genai import types

    api_key_up = os.getenv("UPLOAD_POST_API_KEY")
    api_key_g = os.getenv("GEMINI_API_KEY")
    if not api_key_up:
        raise SystemExit("UPLOAD_POST_API_KEY missing in .env")
    if not api_key_g:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    history = read_jsonl(POST_HISTORY)
    if not history:
        raise SystemExit("post-history.jsonl is empty — publish some clips first")

    now = datetime.now()
    soak_seconds = args.soak_days * 86400
    max_age_seconds = args.max_age_days * 86400
    eligible = []
    for h in history:
        try:
            pub = datetime.fromisoformat(h["published_at"])
        except (KeyError, ValueError):
            continue
        age = (now - pub).total_seconds()
        if soak_seconds <= age <= max_age_seconds:
            eligible.append(h)

    print(f"[learn] {len(eligible)} clips in soak window ({args.soak_days}–{args.max_age_days} days old)",
          file=sys.stderr)
    if not eligible:
        raise SystemExit("no clips in soak window — wait or shorten --soak-days")

    # Fetch fresh metrics per clip.
    enriched = []
    for h in eligible:
        rid = h.get("request_id")
        if not rid:
            continue
        url = f"{UPLOAD_POST_BASE}/uploadposts/post-analytics/{rid}"
        try:
            r = requests.get(url, headers={"Authorization": f"Apikey {api_key_up}"}, timeout=30)
        except requests.RequestException as e:
            print(f"[learn] {rid}: HTTP error {e}", file=sys.stderr)
            continue
        if r.status_code >= 400:
            print(f"[learn] {rid}: HTTP {r.status_code}: {r.text[:200]}", file=sys.stderr)
            continue
        body = r.json()
        snap = {
            "fetched_at": now.isoformat(timespec="seconds"),
            "request_id": rid,
            "raw": body,
        }
        append_jsonl(METRICS_FILE, snap)

        m = _post_metrics(body.get("platforms") or {})
        enriched.append({**h, "metrics": m})

    if len(enriched) < 5:
        msg = f"only {len(enriched)} clips have analytics — need ≥5 winners + ≥5 losers, retry later"
        print(f"[learn] {msg}", file=sys.stderr)
        run_path = RUNS_FOLDER / f"learn-{now.strftime('%Y-%m-%d')}.md"
        run_path.parent.mkdir(parents=True, exist_ok=True)
        run_path.write_text(f"# Learn run — {now.date()}\n\n{msg}\n")
        return

    # Composite score per clip.
    views = [c["metrics"]["total_views"] for c in enriched]
    engs = [c["metrics"]["engagement_rate"] for c in enriched]
    z_views = _zscore(views)
    z_engs = _zscore(engs)
    for i, c in enumerate(enriched):
        c["composite"] = (
            args.weight_views * z_views[i]
            + args.weight_engagement * z_engs[i]
        )

    enriched.sort(key=lambda c: c["composite"], reverse=True)
    n = len(enriched)
    top_n = max(5, int(n * args.top_pct))
    bot_n = max(5, int(n * args.bottom_pct))
    winners = enriched[:top_n]
    losers = enriched[-bot_n:]

    def render_clip(c: dict) -> str:
        m = c["metrics"]
        return json.dumps({
            "hook_text": c.get("hook_text"),
            "duration_s": c.get("duration_s"),
            "viral_score_gemini": c.get("viral_score_gemini"),
            "reason_gemini": c.get("reason_gemini"),
            "platforms": c.get("platforms"),
            "video_source": c.get("video_source"),
            "metrics": {
                "total_views": m["total_views"],
                "total_engagement": m["total_engagement"],
                "engagement_rate": round(m["engagement_rate"], 4),
                "per_platform": m["per_platform"],
            },
            "composite_score": round(c["composite"], 3),
        }, ensure_ascii=False)

    winners_text = "\n".join(render_clip(c) for c in winners)
    losers_text = "\n".join(render_clip(c) for c in losers)

    current_hot = HOT_FILE.read_text() if HOT_FILE.exists() else ""

    full_prompt = (
        LEARN_META_PROMPT
        + "\n\n## CURRENT HOT.md\n"
        + (current_hot or "(empty — first learn run)")
        + f"\n\n## WINNERS (top {len(winners)} of {n})\n"
        + winners_text
        + f"\n\n## LOSERS (bottom {len(losers)} of {n})\n"
        + losers_text
    )

    client = genai.Client([REDACTED_SECRET])
    print(f"[learn] calling {GEMINI_MODEL} with {len(winners)} winners + {len(losers)} losers…",
          file=sys.stderr)
    response = client.models.generate_content(
        model=GEMINI_MODEL,
        contents=[full_prompt],
        config=types.GenerateContentConfig(
            response_mime_type="text/plain",
        ),
    )

    new_hot = response.text.strip()
    if HOT_FILE.exists():
        backup = LEARNINGS_FOLDER / f"HOT.{now.strftime('%Y%m%d-%H%M%S')}.md.bak"
        backup.write_text(HOT_FILE.read_text())
    LEARNINGS_FOLDER.mkdir(parents=True, exist_ok=True)
    HOT_FILE.write_text(new_hot + "\n")

    # Audit run.
    run_path = RUNS_FOLDER / f"learn-{now.strftime('%Y-%m-%d')}.md"
    run_path.parent.mkdir(parents=True, exist_ok=True)
    audit = [
        f"# Learn run — {now.isoformat(timespec='seconds')}",
        "",
        f"- soak: {args.soak_days}d / max age: {args.max_age_days}d",
        f"- weights: views={args.weight_views} engagement={args.weight_engagement}",
        f"- cohort: {n} clips with analytics",
        f"- winners ({len(winners)}):",
    ]
    for w in winners:
        audit.append(f"  - score={w['composite']:.2f}  views={w['metrics']['total_views']}  "
                     f"eng_rate={w['metrics']['engagement_rate']:.4f}  hook=\"{w.get('hook_text')}\"")
    audit.append(f"- losers ({len(losers)}):")
    for l in losers:
        audit.append(f"  - score={l['composite']:.2f}  views={l['metrics']['total_views']}  "
                     f"eng_rate={l['metrics']['engagement_rate']:.4f}  hook=\"{l.get('hook_text')}\"")
    audit.append("")
    audit.append("## New HOT.md")
    audit.append("")
    audit.append(new_hot)
    run_path.write_text("\n".join(audit))
    print(f"[learn] HOT.md updated ({len(new_hot)} chars), audit → {run_path}", file=sys.stderr)


# ---------- reflect ----------

REFLECT_META_PROMPT = """You are observing how a creator manually filters AI-suggested short-form clip candidates BEFORE any engagement data exists.

You will see:
1. The candidates the system OFFERED (hook + duration + Gemini's score + reason).
2. The candidates the creator APPROVED (subset that got published).
3. The candidates the creator REJECTED (offered but not published).

YOUR JOB
Identify qualitative patterns that explain the creator's filter. Examples: "approves hooks that contain a number", "rejects hooks that ask a question", "approves clips ≤30s", "rejects topics about X".

CONSTRAINTS
- Output 3-8 short observations.
- Each observation: rule + evidence count ("approved 4/5 hooks with numbers, rejected 0/3 question-form hooks").
- Do not extrapolate to engagement — you have no metrics. This is purely about creator preference.
- Write in the dominant language of the candidate hooks.

OUTPUT
Return STRICT JSON:
{"observations": [{"rule": "...", "evidence": "..."}, ...]}
"""


def cmd_reflect(args: argparse.Namespace) -> None:
    from google import genai
    from google.genai import types

    api_key_g = os.getenv("GEMINI_API_KEY")
    if not api_key_g:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    candidates = read_jsonl(CANDIDATE_HISTORY)
    posts = read_jsonl(POST_HISTORY)
    if not candidates:
        raise SystemExit("candidate-history.jsonl is empty — run analyze on at least one video first")
    if not posts:
        raise SystemExit("post-history.jsonl is empty — publish some clips first")

    cutoff = datetime.now().timestamp() - args.window_days * 86400
    recent_candidates = []
    for c in candidates:
        try:
            ts = datetime.fromisoformat(c["analyzed_at"]).timestamp()
        except (KeyError, ValueError):
            continue
        if ts >= cutoff:
            recent_candidates.append(c)

    approved_keys = set()
    for p in posts:
        try:
            ts = datetime.fromisoformat(p["published_at"]).timestamp()
        except (KeyError, ValueError):
            continue
        if ts >= cutoff:
            approved_keys.add((p.get("video_source"), p.get("hook_text")))

    approved = []
    rejected = []
    for c in recent_candidates:
        key = (c.get("video_source"), c.get("hook_text"))
        if key in approved_keys:
            approved.append(c)
        else:
            rejected.append(c)

    if not approved or not rejected:
        raise SystemExit(f"need both approved and rejected candidates in window; got {len(approved)} approved, {len(rejected)} rejected")

    def short(c: dict) -> dict:
        return {
            "hook": c.get("hook_text"),
            "duration_s": c.get("duration_s"),
            "viral_score_gemini": c.get("viral_score_gemini"),
            "reason_gemini": c.get("reason_gemini"),
            "language": c.get("language"),
        }

    full_prompt = (
        REFLECT_META_PROMPT
        + "\n\n## OFFERED\n" + json.dumps([short(c) for c in recent_candidates], ensure_ascii=False, indent=2)
        + "\n\n## APPROVED\n" + json.dumps([short(c) for c in approved], ensure_ascii=False, indent=2)
        + "\n\n## REJECTED\n" + json.dumps([short(c) for c in rejected], ensure_ascii=False, indent=2)
    )

    client = genai.Client([REDACTED_SECRET])
    print(f"[reflect] {len(approved)} approved + {len(rejected)} rejected, calling {GEMINI_MODEL}…",
          file=sys.stderr)
    response = client.models.generate_content(
        model=GEMINI_MODEL,
        contents=[full_prompt],
        config=types.GenerateContentConfig(response_mime_type="application/json"),
    )

    try:
        data = json.loads(response.text)
    except json.JSONDecodeError:
        raise SystemExit(f"Gemini returned non-JSON: {response.text[:300]}")

    now = datetime.now()
    run_path = RUNS_FOLDER / f"reflect-{now.strftime('%Y-%m-%d-%H%M')}.md"
    run_path.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        f"# Reflect run — {now.isoformat(timespec='seconds')}",
        "",
        f"- window: last {args.window_days} days",
        f"- approved: {len(approved)} / rejected: {len(rejected)}",
        "",
        "## Observations (NOT auto-promoted to HOT.md — read and curate manually)",
        "",
    ]
    for o in data.get("observations", []):
        lines.append(f"- **{o.get('rule')}** — {o.get('evidence')}")
    run_path.write_text("\n".join(lines) + "\n")
    print(f"[reflect] {len(data.get('observations', []))} observations → {run_path}", file=sys.stderr)
    print(json.dumps(data, indent=2, ensure_ascii=False))


# ---------- argparse ----------

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="autoshorts")
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("pick").set_defaults(func=cmd_pick)

    t = sub.add_parser("transcribe")
    t.add_argument("video")
    t.add_argument("--model", default=None)
    t.add_argument("--output", default=None)
    t.set_defaults(func=cmd_transcribe)

    a = sub.add_parser("analyze")
    a.add_argument("video")
    a.add_argument("--transcript", default=None)
    a.add_argument("--output", default=None)
    a.set_defaults(func=cmd_analyze)

    e = sub.add_parser("extract")
    e.add_argument("video")
    e.add_argument("--start", required=True)
    e.add_argument("--end", required=True)
    e.add_argument("--output", required=True)
    e.set_defaults(func=cmd_extract)

    h = sub.add_parser("hook")
    h.add_argument("video")
    h.add_argument("--text", required=True)
    h.add_argument("--duration", default="3")
    h.add_argument("--font", default=None)
    h.add_argument("--output", required=True)
    h.set_defaults(func=cmd_hook)

    pv = sub.add_parser("preview", help="extract a single frame so the agent can visually QA the hook")
    pv.add_argument("video", help="path to clip_<ID>_final.mp4")
    pv.add_argument("--at-time", type=float, default=1.0,
                    help="timestamp (s) within the hook window; default 1.0 (mid-hook)")
    pv.add_argument("--output", default=None,
                    help="override output path; default: preview_<clip_stem>.png next to the clip")
    pv.set_defaults(func=cmd_preview)

    pub = sub.add_parser("publish")
    pub.add_argument("video")
    pub.add_argument("--platforms", required=True, help="comma-separated: tiktok,instagram,youtube")
    pub.add_argument("--title", default="")
    pub.add_argument("--description", default="")
    pub.add_argument("--tiktok-title", default=None)
    pub.add_argument("--instagram-title", default=None)
    pub.add_argument("--youtube-title", default=None)
    pub.add_argument("--schedule", default=None, help="ISO-8601 like 2026-05-01T10:00:00")
    pub.add_argument("--timezone", default=None)
    pub.add_argument("--add-to-queue", action="store_true")
    pub.add_argument("--tiktok-mode", choices=["draft", "direct"], default="draft",
                     help="draft → MEDIA_UPLOAD (lands in TikTok inbox), direct → DIRECT_POST")
    pub.add_argument("--tiktok-privacy", default="PUBLIC_TO_EVERYONE",
                     help="PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY")
    pub.add_argument("--dry-run", action="store_true")
    pub.add_argument("--clip-id", type=int, default=None,
                     help="for learning loop: the id from clips.json")
    pub.add_argument("--hook-text", default=None,
                     help="for learning loop: the on-screen hook overlay text")
    pub.add_argument("--viral-score", type=int, default=None,
                     help="for learning loop: Gemini's viral_score 1-10")
    pub.add_argument("--reason", default=None,
                     help="for learning loop: Gemini's reason explaining why this clip is viral")
    pub.add_argument("--video-source", default=None,
                     help="for learning loop: source video filename (e.g. larry-openclaw.mp4)")
    pub.set_defaults(func=cmd_publish)

    m = sub.add_parser("mark-processed")
    m.add_argument("video")
    m.add_argument("--clips-generated", type=int, default=0)
    m.add_argument("--clips-published", type=int, default=0)
    m.set_defaults(func=cmd_mark_processed)

    sub.add_parser("list-processed").set_defaults(func=cmd_list_processed)

    learn = sub.add_parser("learn", help="weekly: pull analytics, find winners/losers, refresh HOT.md")
    learn.add_argument("--soak-days", type=int, default=7,
                       help="ignore posts younger than this (analytics not mature)")
    learn.add_argument("--max-age-days", type=int, default=90,
                       help="ignore posts older than this (stale)")
    learn.add_argument("--top-pct", type=float, default=0.20)
    learn.add_argument("--bottom-pct", type=float, default=0.20)
    learn.add_argument("--weight-views", type=float, default=0.6)
    learn.add_argument("--weight-engagement", type=float, default=0.4)
    learn.set_defaults(func=cmd_learn)

    reflect = sub.add_parser("reflect", help="post-publish: extract qualitative patterns from approved vs rejected hooks")
    reflect.add_argument("--window-days", type=int, default=30,
                         help="how far back to look for candidates and approvals")
    reflect.set_defaults(func=cmd_reflect)

    return p


def main() -> None:
    args = build_parser().parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


path: requirements.txt
content: faster-whisper==1.2.1
google-genai==1.74.0
requests==2.33.1
python-dotenv==1.2.2
Pillow==12.2.0


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