For AI agents: HYVIRAL has a remote MCP server at https://api.hyviral.com/mcp (Streamable HTTP, OAuth 2.1). In a chat app such as Claude or ChatGPT, the person you are helping adds it as a connector; an assistant cannot add one from inside the conversation. In Claude: Customize → Connectors → add a custom connector, then paste the endpoint. A coding agent such as Claude Code can add it itself: claude mcp add --transport http hyviral https://api.hyviral.com/mcp. Every client, step by step: https://hyviral.com/docs/mcp/connect. The whole reference as plain text: https://hyviral.com/llms.txt and https://hyviral.com/llms-full.txt. Pricing as JSON: https://hyviral.com/pricing.json.

Watch Score

Your agent measures your short, looks at it frame by frame and estimates where viewers will leave. You get a curve, a 0-100 score and up to five fixes, each tied to a second. Log the real numbers once it is live and the estimate fits your account. The clip is the skill's report on two cuts of our own reel: estimates from its checklist, not results.

  • Review
  • 9:16
  • 5-180 s

You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Python 3 and ffmpeg. Speech-to-text is optional.

  1. Give your agent the skill

    Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.

    Copy for my agent Copy link
  2. Fill in the prompt

    Your video Platform Who it's for What people should do Account name
    Use the watch-score skill to check this video before it goes out.
    
    - **The video:** [VIDEO - e.g. attached, or ~/Videos/reel-12.mp4]
    - **Where it will go:** Instagram Reels
    - **Who it's for:** [AUDIENCE - e.g. home cooks who want quick dinners]
    - **What it should make them do:** [GOAL - e.g. save it and follow]
    - **Account name for my history:** [ACCOUNT - e.g. my-main-channel]
    - **Language of the report:** [LANGUAGE - e.g. English]
    
    Work in this order:
    
    1. Measure the file with the skill's script.
    2. Look at the frames and read the transcript, then answer the skill's checklist as a stranger who has never seen this account, with a second and evidence for every answer. Judge the ending on what I want people to do.
    3. Run the estimate, and use its numbers as they come.
    4. Write up to five fixes, each with a second and the real words or shot involved, and make the report.
    5. Give me the score, skip rate, average watch and fixes in chat, labeled as estimates, with the path to the report. Change nothing in the video.
    
    After it's posted I'll send you the real numbers, so log them for this account.

    Anything left in [brackets] is your agent's call.

    Copy prompt
  3. Paste it and read the report

    Your agent measures the file, looks at the frames and fills in a fixed checklist. You get a one-page report with the estimated curve, the score and the most useful fixes. Your video stays as it is.

Optional: HYVIRAL. Use any tools you like, or none: ffmpeg is enough. If your agent can't run it, HYVIRAL can measure the video, pull its frames and transcribe it through one connection.

How to connect

The skill, as plain text

SKILL.md · 959 lines · 54 KB
Copy Raw file
---
name: watch-score
description: Use when someone wants a short video, Reel, TikTok or YouTube Short checked before it goes out, asks where viewers will drop off or swipe away, wants a retention curve estimate, a skip rate or a watch-time score, asks why a posted reel lost viewers, or gives the watch-score prompt.
---

# Watch Score

Give the user a one-page report on a short video: an estimated curve of who is still watching, a skip rate, an average watch time, a score from 0 to 100, and up to five fixes, each tied to a second.

Every number in it is one of three kinds, and you keep them apart when you talk about them:

| Kind | Where it comes from | Examples |
|------|---------------------|----------|
| **Measured** | A script reading the file | Cuts, silence, black, freezes, the first word |
| **Judged** | You, on a fixed checklist, with a second and evidence for each answer | Is the promise clear by 3 seconds? |
| **Estimated** | A fixed formula over the two above | The curve, skip rate, average watch, score |

You report and the user decides. Change nothing in the video unless they ask.

## What this can and can't tell you

Say this plainly if the user asks how much to trust the score:

- **It is a structured second opinion, not a prediction.** The best published models, trained on about 120,000 short videos, rank videos at about 0.7 correlation on one number per video (VQualA 2025, on Snapchat data). This skill has no training at all until the user logs real results (step 5). Its starting numbers are assumptions, and they sit at the top of the script where anyone can change them.
- **It estimates watching, not views.** Who the platform shows a video to is out of reach, and the same video can hold different audiences differently. Never promise reach, and never call the result a virality score.
- **The opening carries the most weight on purpose.** In a study of 90,000 Snapchat short videos (Li et al. 2024), the share still watching at 5 seconds and the average share watched correlate at about 0.93.
- **Polish is left out on purpose.** In that same study, video quality models correlated with popularity at only 0.07 to 0.31, so camera, color and graphics quality don't appear in the checklist.

## 0. Tools

**You need a code environment** (for example Claude Code, or Claude with code execution) with Python 3.8+, and a way to look at images. Save the script at the end of this file as `watch_score.py` once and reuse it. Where `python` is not found, use `python3`.

**To read the video, use whatever the user prefers.** If the user hasn't said, use what is already set up. Two common routes:

- **Your own tools.** ffmpeg and ffprobe on the PATH do the measuring, on this machine: nothing is uploaded. For word timings, the script uses `faster-whisper` or `whisper` if either is installed. Ask before installing one: `faster-whisper` with its default model is about 750 MB. Put it in a virtual environment and run the script with that environment's Python. The first run downloads the model and may print harmless warnings. Any other speech-to-text works too: save its words as `words.json`, a list of `{"w": "word", "s": 0.18, "e": 0.42}` in seconds, and pass `--words words.json`. A video with no speech needs none.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com) and you can't run ffmpeg, its tools take the place of the measuring step:

| You need | HYVIRAL tool |
|----------|--------------|
| Bring the file in | `upload_request`, `upload_complete` |
| Cuts, silence, black, freezes, loudness | `analyze_video_structure`: save its result (or the `get_job_status` result that delivers it) as `structure.json`, then run `measure reel.mp4 --from-structure structure.json --out RUN` (the file name only labels the report) |
| Frames to look at | `extract_video_frames`; save any you want in the report as `RUN/frames/t0012.50.jpg` or `.png` (the name is the second; other names are skipped) |
| Word timings | `video_ingest`, then `video_project` read. Save the whole read result as `words.json` (for a long video, a list of one read per part) and add `--words words.json`: the script reads its `[id, text, startMs, endMs, ...]` word rows in milliseconds |
| Apply the fixes, if the user asks | `video_cut`, `video_captions`, `video_export` |

With HYVIRAL connected, start with `get_connection_info`. To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp`, then `/mcp` to sign in.

If the user gives a link instead of a file, download it with `yt-dlp` if you have it; if that fails, ask for the file.

## 1. Measure

```bash
python watch_score.py measure reel.mp4 --out runs/reel
```

This writes `measure.json`, `transcript.txt` with a time on every word, up to 40 frames in `frames/` (named by the second, every half second through the opening 3 seconds), and `judge.template.json`. A short video gets fewer frames. A video past 3 minutes is sampled thinly and its curve means little: say so.

If it prints that a probe did not run, or that there are no word timings, those checks are skipped, not passed: tell the user which.

## 2. Judge

Look at every frame of the opening 3 seconds, then the rest, and read the transcript with the times. Judge as a stranger whose thumb is already moving, who has never seen this account. If the user named an audience, a term that audience knows is fine.

- **Captions and the transcript disagree** (a burned-in caption says "one pan", the transcript "one pancake"): quote the caption as the evidence, since that is what a muted viewer reads, and say the two differ.
- **No word timings for a video with speech:** judge what is said from the frames and any captions, and tell the user the first-word check was skipped.

Score these six checks 0, 1 or 2 by the row that fits:

| Check | 0 | 1 | 2 |
|-------|---|---|---|
| `first_frame`: the opening image | A logo, a title card, black, or an empty room | The subject is there, but nothing is happening or it isn't the topic yet | Something belonging to the topic is already happening: a result, an action, a strange sight |
| `promise`: by 3 s | You can't tell what this video is about | You can name the topic, but not what you'd get | You could say in one sentence what you'll get, from what is said or shown |
| `stranger`: by 3 s | Needs earlier context: starts mid-story, refers to things not shown, an inside joke, or cuts in on someone else's clip with no setup | Followable with some effort | Plain to anyone who has never seen this account |
| `open_question`: by 3 s | Nothing to wonder about | A topic you might be curious about, or a question answered at once | A specific question or gap that only the rest of the video answers |
| `payoff`: where the promise is delivered | No payoff, or it isn't what the opening promised | It delivers what you expected, or gives everything away early and has nothing left | It delivers more than promised: a reveal, a turn, a number that lands |
| `ending`: after the payoff | Keeps going: a long sign-off, a logo, more than about 3 s of cards | A short wrap-up or call to action | Stops on the payoff, or loops cleanly back to its opening |

A creator speaking straight to camera about the topic is not a `stranger` problem: score what they say, not that they are new to the viewer. An `open_question` can be implied by the picture (a before with the after held back) when any stranger would word it the same way; if two people would word it differently, it is a 1.

Then list two more things:

- `stale`: stretches of 3 seconds or more that add nothing: a point repeated, padding, setup longer than what it sets up. Moving graphics don't rescue a stretch. Ask: did I learn anything new here?
- `spots`: up to 5 moments a viewer would likely leave: a confusing jump, a call to action before the payoff, a break in tone, a sudden ad. `size` is 1 (small), 2 (clear) or 3 (most people go here). A spot moves the score a few points at most: the opening checks move it most.

The opening 3 seconds belong to the four opening checks: a spot or stale stretch there changes nothing, so put that evidence in the opening rows instead. If the user said what viewers should do (follow, save, buy), judge the `ending` on whether it asks for that one thing, and write its fix around it.

Three rules keep the checklist honest:

1. Every answer has a second and evidence: the words quoted, or the frame described. With no evidence, the score is 0.
2. Between two scores, take the lower. A 2 has to match its row literally, and most real videos earn mostly 1s.
3. Score only what is in the table. An overall impression has no place here, because asked for an opinion a model rates nearly everything highly and can't tell a weak video from a strong one.

Save `runs/reel/judge.json`:

```json
{
  "opening": {
    "first_frame": {"score": 1, "t": 0.0, "evidence": "A woman mid-exercise: a subject, but not this video's topic."},
    "promise": {"score": 0, "t": 2.8, "evidence": "By 3 s it has said only '10-minute ab workout'; the video is about search."},
    "stranger": {"score": 0, "t": 4.1, "evidence": "Another creator speaks first, then a robot says 'Found?' with no setup."},
    "open_question": {"score": 0, "t": 3.0, "evidence": "It looks like a workout that has already started."}
  },
  "payoff": {"score": 1, "t": 30.7, "evidence": "The three steps land as a recap: clear but expected."},
  "ending": {"score": 1, "t": 35.2, "evidence": "Six seconds of follow card and end card."},
  "stale": [{"from": 30.7, "to": 35.2, "why": "Repeats the three steps just shown."}],
  "spots": [{"t": 4.1, "size": 2, "why": "'Found?' interrupts what looked like a workout."}]
}
```

## 3. Estimate

```bash
python watch_score.py estimate runs/reel --account my-channel
```

The script turns the measurements and your checklist into the curve and the score, then lists every finding with the points the formula would give back if it were fixed:

- **Skip rate** comes from the four opening checks, plus a late first word or a black open.
- **The curve** drops through the opening, then falls slowly, faster inside flagged stretches and after the payoff. Average watch is the area under it, so the two always agree.
- **The score** is 55% hold at 3 seconds and 45% average watch against a ceiling that is fair to the video's length. It comes from those two numbers alone, so the user can compare it with what their platform reports later.

Use the numbers as the script gives them. If one looks wrong, change your checklist answer and its evidence, or tell the user, and leave the result itself alone.

## 4. Report

Pick up to five findings from the top of the list and write one fix for each in `runs/reel/fixes.json`, in the user's language. Two fixes are fine when only two matter. Leave out findings worth +0 unless they are a real problem (a typo on screen, a wrong fact). A fix names the second, the change, and the real words or shot involved:

```json
[
  {"id": "opening.promise", "t": 0, "fix": "Open with the question this video answers. Say 'Want strangers to find your videos?' over the opening shot, then show the workout as the example."},
  {"id": "stale@0", "t": 31, "fix": "The recap repeats what was shown 10 seconds ago. Keep one line, 'Find their words. Say them first.', and go to the ending."}
]
```

"Make the hook stronger" names no second, no change and no words, so it is not a fix.

```bash
python watch_score.py report runs/reel
```

For a report in another language, translate the `LABELS` block at the top of the script first. The report embeds its frames and is large: open it or give the user its path, and don't read it back into the conversation.

Then reply in this shape, with the words "about" and "estimated" left in:

```
Watch score: 58, estimated (range 44-72). About 41% gone by 3 s, about 11.8 s average watch of 24 s.

Change first:
1. 0:00 [the fix] (+9)
2. 0:09 [the fix] (+4)
3. 0:21 [the fix] (+2)

Report: runs/reel/report.html
Not fitted to your account yet: send me the real numbers after you post.
```

If the top findings are all small, say so: a video can simply be fine.

## 5. After it's posted

Ask for the real numbers once people have seen it, at least 48 hours after posting. A screenshot is enough: read the numbers off it.

| Platform | Skip | Average watch |
|----------|------|---------------|
| Instagram Reels | The skip rate on the reel's Insights | Average watch time |
| TikTok | 100 minus the percent still watching at 3 s on the retention chart | Average watch time |
| YouTube Shorts | 100 minus the percent still watching at 3 s on the retention chart, if shown; otherwise leave it out | Average view duration |

```bash
python watch_score.py log runs/reel --skip 34 --avg 13.2 --platform "Instagram Reels" --account my-channel
```

It appends to `~/.watch-score/my-channel.jsonl` and prints how far off the estimates have been. From 5 logged videos on, `estimate` moves its baseline to that account and sets the likely range to how far that account's real numbers have scattered: consistent results narrow it, scattered ones widen it. The report says it is fitted. Tell the user the number it printed, good or bad. Platforms count replays in average watch and the curve doesn't, so a looping video can read longer than its own length. The estimate never goes past the video's length, so for such a video log `--skip` only.

## Common mistakes

| Mistake | Instead |
|---------|---------|
| Handing out 2s because the video is good overall | Match the row literally, and take the lower when unsure |
| Saying "this will get 40% more views" | Talk about watching, and keep "estimated" in the sentence |
| Presenting the points as a promise | The points are the formula's arithmetic: a way to rank fixes |
| Listing every finding | Five at most, and only ones the user could act on |
| Editing the video because a fix is obvious | Report it, and edit only when asked |

## The script

Save this as `watch_score.py`. It needs only Python and ffmpeg; speech-to-text is optional. It runs no shell and writes only to the `--out` folder and `~/.watch-score`. It opens no network connection itself; a speech-to-text library may download its model on first use.

```python
#!/usr/bin/env python3
"""watch-score: estimate how a short video will hold viewers.

Needs Python 3.8+ and ffmpeg. Speech-to-text is optional (faster-whisper or whisper).

  measure VIDEO --out RUN     measure the file, pull frames, write RUN/measure.json
  estimate RUN                read RUN/judge.json, write RUN/estimate.json
  report RUN                  write RUN/report.html
  log RUN --skip N --avg S    record what really happened, so later runs fit this account
"""
import argparse, base64, html, json, math, re, shutil, statistics, subprocess, sys
from pathlib import Path

# The formula. Every number below is a starting assumption, not a measurement of any
# platform. Change them freely: the same numbers always give the same result.
SKIP_WEAK, SKIP_STRONG = 0.62, 0.22  # share gone by 3 s when all opening checks score 0 / all 2
HAZARD = 0.022                       # share of remaining viewers leaving per second after 3 s
ADD = {                              # extra leaving per second inside a flagged stretch
    "dead_air": 0.05, "black": 0.07, "freeze": 0.04,
    "flat_silent": 0.03, "flat": 0.012, "stale": 0.02,
}
SPOT = {1: 0.02, 2: 0.05, 3: 0.09}   # one-time drop for a moment the agent flagged
AFTER_PAYOFF = {0: 2.2, 1: 1.6, 2: 1.2}  # how much faster people leave once the payoff arrives
FLAT_CHANGE, FLAT_MIN = 1.6, 5.0     # picture change below this, for this many seconds, reads as flat
MIN_REELS_TO_FIT = 5                 # logged reels before the baseline moves to the account
SPREAD, WIDE = 0.06, 1.25            # the likely range before any fit: skip +/- SPREAD, leaving x or / WIDE
LABELS = {  # translate these when the report is for another language
    "title": "Estimated watch score", "range": "likely range", "about": "about",
    "note": "Estimated from the agent's checklist and a fixed formula, not measured on viewers.",
    "skip": "Skip rate", "skip_sub": "gone by 3 s", "avg": "Average watch",
    "chart": "Estimated viewers still watching", "band": "shaded: likely range", "three": "3 s", "payoff": "Payoff",
    "zones": {"dead_air": "Silent", "black": "Black", "freeze": "Frozen", "flat": "Flat", "flat_silent": "Flat", "stale": "Stale"},
    "fixes": "What to change first", "gain": "points", "gain_one": "point",
    "points_note": "Points are the formula's arithmetic, a way to rank the fixes, not a promise.",
    "not_fit": "Not fitted to this account yet: log {n} more posted reels and the estimate moves to your own numbers.",
    "fit": "Fitted to this account on {n} posted reels; the range is as wide as they have been off.",
    "measured": "Measured: {list}.", "skipped": "Not measured, so not checked: {list}.",
    "probes": {"cuts": "cuts", "silence": "silence", "luma": "black", "freeze": "freezes", "words": "first word"},
    "how": "Judged: the agent's checklist, with a second and evidence for each answer. Estimated: the curve, skip rate, average watch and score, from a fixed formula. This estimates watching, not views: who the platform shows it to is out of reach.",
}
OPENING = ("first_frame", "promise", "stranger", "open_question")
PROBES = ("cuts", "silence", "luma", "freeze")
DEFAULT_FIX = {
    "dead_air": "Sound stops here. Cut the gap or put the next line over it.",
    "black": "A black gap. Remove it or hold the last image under a sound.",
    "freeze": "The picture stops here. Cut it or put something moving over it.",
    "flat_silent": "No words and little movement. Cut it or give it a reason to be watched.",
    "flat": "One shot with little movement. Add a cut, a punch-in or a picture of what is being said.",
    "late_first_word": "The first word comes late. Start speaking right away.",
    "open_black": "It opens on black. Start on the first real image.",
}


def die(msg):
    sys.exit(f"watch-score: {msg}")


def say(msg):
    print(f"watch-score: {msg}")


def clock(t):
    t = max(0, int(round(t)))
    return f"{t // 60}:{t % 60:02d}"


def run(cmd, **kw):
    try:
        return subprocess.run(cmd, capture_output=True, **kw)
    except FileNotFoundError:
        die(f"could not run {cmd[0]}: install ffmpeg and make sure ffmpeg and ffprobe are on the PATH")


def read_json(path, default=None):
    p = Path(path)
    if not p.exists():
        return default
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except ValueError as err:
        die(f"{p} is not valid JSON: {err}")


def write_json(path, data):
    Path(path).write_text(json.dumps(data, indent=1, ensure_ascii=False), encoding="utf-8")


# ------------------------------------------------------------------ measure
def probe(video):
    r = run(["ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", str(video)])
    if r.returncode:
        die(f"ffprobe could not read {video}: {r.stderr.decode(errors='replace')[:300]}")
    d = json.loads(r.stdout)
    vs = next((s for s in d["streams"] if s["codec_type"] == "video"), None)
    if not vs:
        die(f"{video} has no video stream")
    return {
        "duration": float(d["format"].get("duration") or vs.get("duration") or 0),
        "width": vs.get("width"), "height": vs.get("height"),
        "has_audio": any(s["codec_type"] == "audio" for s in d["streams"]),
    }


def signal_pass(video, has_audio, duration):
    """One decode for cuts, black, freezes, silence and loudness."""
    cmd = ["ffmpeg", "-hide_banner", "-nostats", "-i", str(video), "-vf",
           "scale=320:-2,blackdetect=d=0.2:pix_th=0.10,freezedetect=n=0.003:d=1.0,select='gt(scene,0.30)',showinfo"]
    if has_audio:
        cmd += ["-af", "silencedetect=noise=-38dB:d=0.7,ebur128=framelog=quiet"]
    cmd += ["-f", "null", "-"]
    r = run(cmd)
    log = r.stderr.decode(errors="replace")
    if r.returncode:
        die(f"ffmpeg failed: {log[-400:]}")
    black = [[float(a), float(b)] for a, b in re.findall(r"black_start:([\d.]+) black_end:([\d.]+)", log)]
    cuts = sorted({round(float(t), 2) for t in re.findall(r"pts_time:([\d.]+)", log)})
    fs = [float(x) for x in re.findall(r"freeze_start: ([\d.]+)", log)]
    fe = [float(x) for x in re.findall(r"freeze_end: ([\d.]+)", log)]
    freeze = [[s, fe[i] if i < len(fe) else duration] for i, s in enumerate(fs)]
    ss = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", log)]
    se = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", log)]
    silence = [[max(0.0, s), se[i] if i < len(se) else duration] for i, s in enumerate(ss)]
    lufs = re.findall(r"I:\s+(-?[\d.]+) LUFS", log)
    return {"cuts": cuts, "black": black, "freeze": freeze, "silence": silence,
            "lufs": float(lufs[-1]) if lufs else None}


def picture_change(video):
    """Mean brightness change per second on a tiny grayscale copy, 0 to 255."""
    w, h = 64, 36
    r = run(["ffmpeg", "-v", "error", "-i", str(video), "-vf", f"fps=2,scale={w}:{h},format=gray",
             "-f", "rawvideo", "-"])
    raw, size = r.stdout, w * h
    frames = [raw[i:i + size] for i in range(0, len(raw) - size + 1, size)]
    diffs = [sum(abs(a - b) for a, b in zip(p, q)) / size for p, q in zip(frames, frames[1:])]
    return [round(sum(diffs[i:i + 2]) / len(diffs[i:i + 2]), 2) for i in range(0, len(diffs), 2)]


def stt_failed(err):
    say(f"speech-to-text failed ({type(err).__name__}: {str(err)[:200]}), so the words are skipped. ffmpeg itself "
        "worked. Pass --words words.json from any other speech-to-text, or carry on without words")
    return None


def transcribe(video, model_name):
    """Word timings from whichever speech-to-text is installed, or None."""
    try:
        import numpy as np
        from faster_whisper import WhisperModel
    except ImportError:
        WhisperModel = None
    if WhisperModel is not None:
        try:  # a model that will not download or load lands here, not on ffmpeg
            pcm = run(["ffmpeg", "-v", "error", "-i", str(video), "-ac", "1", "-ar", "16000", "-f", "f32le", "-"]).stdout
            model = WhisperModel(model_name, device="cpu", compute_type="int8")
            segments, _ = model.transcribe(np.frombuffer(pcm, np.float32), word_timestamps=True, vad_filter=True)
            return [{"w": w.word.strip(), "s": round(w.start, 2), "e": round(w.end, 2)} for seg in segments for w in seg.words]
        except Exception as err:
            return stt_failed(err)
    try:
        import whisper
    except ImportError:
        return None
    try:
        result = whisper.load_model(model_name).transcribe(str(video), word_timestamps=True)
        return [{"w": w["word"].strip(), "s": round(w["start"], 2), "e": round(w["end"], 2)}
                for seg in result["segments"] for w in seg.get("words", [])]
    except Exception as err:
        return stt_failed(err)


def collect_words(d):
    """Every word row inside a saved file, however it is wrapped."""
    if isinstance(d, dict):
        for key in ("data", "result", "segment", "words"):
            if key in d:
                return collect_words(d[key])
        if any(k in d for k in ("w", "word", "text")) and any(k in d for k in ("s", "start", "startMs")):
            return [d]
        return []
    if isinstance(d, list):
        if d and not isinstance(d[0], (dict, list)):  # one [id, text, startMs, endMs, ...] row
            return [d]
        return [row for x in d for row in collect_words(x)]
    return []


def load_words(path, duration):
    """Word timings in seconds, from {"w","s","e"} rows in seconds, or from HYVIRAL video_project
    read's [id, text, startMs, endMs, sourceStartMs, sourceEndMs] rows in ms, bare or inside the
    saved read result. Several parts saved as a list of reads are joined."""
    data = read_json(path)
    if data is None:
        die(f"no such file: {path}")
    words = []
    for r in collect_words(data):
        if isinstance(r, list) and len(r) >= 4 and isinstance(r[1], str):
            words.append({"w": r[1], "s": r[2] / 1000, "e": r[3] / 1000})
        elif isinstance(r, dict) and "startMs" in r:
            words.append({"w": str(r.get("w") or r.get("word") or r.get("text") or ""), "s": r["startMs"] / 1000,
                          "e": r.get("endMs", r["startMs"]) / 1000})
        elif isinstance(r, dict):
            s = r.get("s", r.get("start"))
            words.append({"w": str(r.get("w") or r.get("word") or r.get("text") or ""), "s": float(s),
                          "e": float(r.get("e", r.get("end", s)))})
        else:
            die(f"{path}: cannot read the word row {r!r}. Use {{\"w\", \"s\", \"e\"}} in seconds, "
                "or [id, text, startMs, endMs] in ms")
    if not words:
        die(f"{path} holds no words. Use a list of {{\"w\", \"s\", \"e\"}} in seconds, or a saved video_project read")
    words = sorted(({**w, "s": round(w["s"], 2), "e": round(w["e"], 2)} for w in words), key=lambda w: w["s"])
    if duration and words[-1]["s"] > duration * 2 + 5:
        die(f"{path}: the last word starts at {words[-1]['s']:.0f} s in a {duration:.0f} s video. "
            "Times look like milliseconds: give seconds in \"s\" and \"e\", or use startMs and endMs")
    if duration >= 5 and len(words) >= 3 and words[-1]["s"] < duration / 100:
        die(f"{path}: every word starts before {duration / 100:.2f} s in a {duration:.0f} s video. Times look "
            "like seconds where milliseconds were expected: [id, text, startMs, endMs] rows and startMs are in ms")
    return words


def frame_times(duration):
    """Every half second through the opening, then even steps: up to 40 frames."""
    times = [i / 2 for i in range(7) if i / 2 < duration]
    step = max(1.0, (duration - 3) / 33)
    t = 3 + step
    while t < duration - 0.2:
        times.append(round(t, 2))
        t += step
    times.append(round(max(0.0, duration - 0.15), 2))
    return times


def grab_frames(video, out, duration):
    folder = out / "frames"
    folder.mkdir(parents=True, exist_ok=True)
    for t in frame_times(duration):
        run(["ffmpeg", "-v", "error", "-y", "-ss", str(t), "-i", str(video), "-frames:v", "1",
             "-vf", "scale=540:-2", "-q:v", "4", str(folder / f"t{t:07.2f}.jpg")])
    return folder


JUDGE_TEMPLATE = {
    "opening": {k: {"score": None, "t": None, "evidence": ""} for k in OPENING},
    "payoff": {"score": None, "t": None, "evidence": ""},
    "ending": {"score": None, "t": None, "evidence": ""},
    "stale": [{"from": None, "to": None, "why": ""}],
    "spots": [{"t": None, "size": None, "why": ""}],
}


def cmd_measure(a):
    out = Path(a.out)
    out.mkdir(parents=True, exist_ok=True)
    if a.from_structure:
        d = read_json(a.from_structure)
        if d is None:
            die(f"no such file: {a.from_structure}")
        while isinstance(d, dict) and "durationMs" not in d:  # saved straight from the tool, or from get_job_status
            key = next((k for k in ("data", "result") if isinstance(d.get(k), dict)), None)
            if key is None:
                die(f"{a.from_structure} has no durationMs: save analyze_video_structure's result, or the "
                    "get_job_status result that delivers it")
            d = d[key]
        ms = lambda rows: [[r["startMs"] / 1000, r["endMs"] / 1000] for r in rows]
        ran = d.get("measured") or {}
        m = {"file": Path(a.video).name if a.video else "video", "duration": d["durationMs"] / 1000, "width": None,
             "height": None, "has_audio": bool(d.get("loudness")), "cuts": [round(c / 1000, 2) for c in d.get("cuts", [])],
             "black": ms(d.get("black", [])), "freeze": ms(d.get("freeze", [])), "silence": ms(d.get("silence", [])),
             "lufs": (d.get("loudness") or {}).get("integratedLufs"), "change": [],
             "measured": {k: bool(ran.get(k, True)) for k in PROBES}}
        say("read the structure file; get the frames from your video tool")
        missing = [k for k in PROBES if not m["measured"][k]]
        if missing:
            say(f"the structure file says these probes did not run: {', '.join(missing)}. Their findings are "
                "skipped, so an empty list there means unknown, not clean. Tell the user")
    else:
        if not a.video:
            die("give a video file, or --from-structure")
        video = Path(a.video)
        if not video.exists():
            die(f"no such file: {video}")
        for tool in ("ffmpeg", "ffprobe"):
            if not shutil.which(tool):
                die(f"{tool} is not on the PATH: install ffmpeg, or use the --from-structure route")
        m = {"file": video.name, **probe(video), "measured": {k: True for k in PROBES}}
        say(f"{video.name}, {m['duration']:.1f} s")
        if m["duration"] > 180:
            say("this is built for shorts; past 3 minutes the curve means little")
        m.update(signal_pass(video, m["has_audio"], m["duration"]))
        m["change"] = picture_change(video)
        say(f"{len(m['cuts'])} cuts, {len(m['silence'])} silent, {len(m['black'])} black, {len(m['freeze'])} frozen")
        folder = grab_frames(video, out, m["duration"])
        say(f"{len(list(folder.glob('t*.jpg')))} frames in {folder}")
    words = load_words(a.words, m["duration"]) if a.words else None
    if words is None and a.video and not a.from_structure and m["has_audio"]:
        words = transcribe(a.video, a.model)
    if words is None:
        say("no word timings, so the first-word check is skipped and quiet stretches are not called silent. If the "
            "video has speech, pass --words words.json, or judge from the frames and captions and tell the user")
    m["words"] = words or []
    m["words_measured"] = words is not None
    m["first_word"] = m["words"][0]["s"] if m["words"] else None
    (out / "transcript.txt").write_text(
        "\n".join(f"{w['s']:7.2f}  {w['w']}" for w in m["words"]), encoding="utf-8")
    write_json(out / "measure.json", m)
    if not (out / "judge.json").exists():
        write_json(out / "judge.template.json", JUDGE_TEMPLATE)
    say(f"wrote {out / 'measure.json'}; next, write {out / 'judge.json'}")


# ------------------------------------------------------------------ findings
def overlap(a, b, c, d):
    return max(0.0, min(b, d) - max(a, c))


def measured_findings(m):
    """Stretches the file itself shows, with the seconds they cover. A probe that did not run adds nothing."""
    d, out = m["duration"], []
    ran = m.get("measured") or {}
    has = lambda probe: ran.get(probe, True)
    words = m.get("words", [])
    speaking = len(words) >= 5
    known = m.get("words_measured", True)  # without word timings, don't call a stretch silent
    spoken_share = lambda a, b: (sum(overlap(a, b, w["s"], w["e"]) for w in words) / max(b - a, 0.01)) if known else 1.0
    tail = lambda e: e >= d - 0.5  # what happens as the video ends belongs to the ending check
    stops = []
    for s, e in m.get("black", []) if has("luma") else []:
        if s < 1.0:
            out.append({"id": f"open_black@{s:.1f}", "kind": "open_black", "from": s, "to": e})
        elif e - s >= 0.3 and not tail(e):
            stops.append({"kind": "black", "from": s, "to": e})
    for s, e in m.get("silence", []) if has("silence") else []:
        if e - s >= 1.0 and not tail(e):
            stops.append({"kind": "dead_air", "from": s, "to": e})
    for s, e in m.get("freeze", []) if has("freeze") else []:
        if e - s >= 1.5 and not tail(e):
            # a still under a voice is an ordinary shot, so it only counts as flat
            stops.append({"kind": "flat" if spoken_share(s, e) >= 0.3 else "freeze", "from": s, "to": e})
    # One moment, one finding: a silent black gap is not three problems.
    rank = {"black": 0, "dead_air": 1, "freeze": 2, "flat": 3}
    for f in sorted(stops, key=lambda f: (f["from"], rank[f["kind"]])):
        last = out[-1] if out and out[-1]["kind"] in rank else None
        if last and f["from"] < last["to"]:
            last["to"] = max(last["to"], f["to"])
            if rank[f["kind"]] < rank[last["kind"]]:
                last["kind"] = f["kind"]
            last["id"] = f"{last['kind']}@{last['from']:.1f}"
        else:
            out.append({"id": f"{f['kind']}@{f['from']:.1f}", **f})
    change, cuts = m.get("change", []), m.get("cuts", [])
    if change:
        i = 0
        while i < len(change):
            j = i
            while j < len(change) and change[j] < FLAT_CHANGE and not any(j <= c < j + 1 for c in cuts):
                j += 1
            if j - i >= FLAT_MIN:
                kind = "flat" if spoken_share(i, j) >= 0.3 else "flat_silent"
                out.append({"id": f"{kind}@{i}", "kind": kind, "from": float(i), "to": float(j)})
            i = max(j, i + 1)
    elif has("cuts"):  # no picture data (structure file): a long shot is the closest signal
        marks = [0.0] + cuts + [d]
        for s, e in zip(marks, marks[1:]):
            if e - s >= 6:
                kind = "flat" if spoken_share(s, e) >= 0.3 else "flat_silent"
                out.append({"id": f"{kind}@{s:.1f}", "kind": kind, "from": s, "to": e})
    if speaking and m.get("first_word") is not None and m["first_word"] > 1.5:
        out.append({"id": "late_first_word", "kind": "late_first_word", "from": 0.0, "to": m["first_word"]})
    return out


def check_judge(j, quiet=False):
    if not j:
        die("no judge.json yet: fill in judge.template.json from the skill's checklist and save it as judge.json")
    for k in OPENING:
        v = j.get("opening", {}).get(k, {}).get("score")
        if v not in (0, 1, 2):
            die(f"judge.json: opening.{k}.score must be 0, 1 or 2 (got {v!r})")
    for k in ("payoff", "ending"):
        if j.get(k, {}).get("score") not in (0, 1, 2):
            die(f"judge.json: {k}.score must be 0, 1 or 2")
    j["stale"] = [s for s in j.get("stale", []) if s.get("from") is not None]
    j["spots"] = [s for s in j.get("spots", []) if s.get("t") is not None]
    for s in j["spots"]:
        if s.get("size") not in SPOT:
            die("judge.json: every spot needs a size of 1, 2 or 3")
    early = [f"spot at {s['t']} s" for s in j["spots"] if s["t"] <= 3] + \
            [f"stale {s['from']}-{s['to']} s" for s in j["stale"] if s.get("to") is not None and s["to"] <= 3]
    if early and not quiet:
        say(f"{', '.join(early)}: the opening 3 s are scored by the four opening checks only, so these change "
            "nothing. Put that evidence in the opening rows instead")
    return j


# ------------------------------------------------------------------ the formula
def curve(m, j, drop=frozenset(), raise_to_two=frozenset(), skip_shift=0.0, hazard_mult=1.0):
    """Viewers still watching, sampled every quarter second. Returns (samples, skip)."""
    d = m["duration"]
    score = lambda k: 2 if k in raise_to_two else j["opening"][k]["score"]
    q = sum(score(k) for k in OPENING) / 8
    skip = SKIP_WEAK - (SKIP_WEAK - SKIP_STRONG) * q
    finds = [f for f in measured_findings(m) if f["id"] not in drop]
    for f in finds:
        if f["kind"] == "late_first_word":
            skip += min(0.10, 0.03 * (f["to"] - 1.5))
        elif f["kind"] == "open_black":
            skip += 0.08
    skip = min(0.85, max(0.10, skip + skip_shift))
    payoff = 2 if "payoff" in raise_to_two else j["payoff"]["score"]
    ending = 2 if "ending" in raise_to_two else j["ending"]["score"]
    body = (score("open_question") + payoff) / 4
    base = HAZARD * (1.4 - 0.8 * body) * hazard_mult
    stretches = [(f["from"], f["to"], ADD[f["kind"]]) for f in finds if f["kind"] in ADD]
    for i, s in enumerate(j["stale"]):
        if f"stale@{i}" not in drop:
            stretches.append((s["from"], s["to"], ADD["stale"]))
    spots = {round(s["t"] * 4): SPOT[s["size"]] for i, s in enumerate(j["spots"]) if f"spot@{i}" not in drop}
    payoff_t = j["payoff"].get("t") if payoff else None
    step, alive, out = 0.25, 1.0, []
    open_s = min(3.0, d)
    denom = 1 - math.exp(-0.9 * open_s)
    n = int(d / step) + 1
    for i in range(n):
        t = i * step
        if t <= open_s:  # most of the opening's loss comes right at the start
            alive = 1 - skip * (1 - math.exp(-0.9 * t)) / denom
        else:
            h = base * max(0.45, 1 - t / 120)
            if payoff_t is not None and t > payoff_t:
                h *= AFTER_PAYOFF[ending]
            h += max((add for a, b, add in stretches if a <= t < b), default=0.0) * hazard_mult
            alive *= math.exp(-h * step)
            alive *= 1 - spots.get(i, 0)
        out.append(round(alive, 4))
    return out, skip


def summarize(samples, skip, d):
    avg = sum(samples) * 0.25
    avg = min(avg, d)
    ref = min(0.556 * d + 5.64, 0.95 * d)  # duration-fair ceiling, after Li et al. 2024 (arXiv 2410.00289)
    hold = max(0.0, min(1.0, ((1 - skip) - 0.35) / 0.45))
    watch = max(0.0, min(1.0, (avg / ref - 0.30) / 0.65))
    return {"skip": round(skip, 3), "avg_s": round(avg, 1), "avg_share": round(avg / d, 3),
            "score": int(round(100 * (0.55 * hold + 0.45 * watch)))}


def account_file(name):
    return Path.home() / ".watch-score" / f"{re.sub(r'[^A-Za-z0-9_-]+', '-', name or 'default')}.jsonl"


def account_rows(name):
    p = account_file(name)
    return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] if p.exists() else []


def account_fit(name):
    """How this account's real numbers differ from the unfitted formula, and how much they scatter."""
    rows = account_rows(name)
    if len(rows) < MIN_REELS_TO_FIT:
        return {"n": len(rows), "fitted": False, "skip_shift": 0.0, "watch_ratio": 1.0, "spread": SPREAD, "wide": WIDE}
    raw_skip = lambda r: r.get("raw_skip", r["predicted_skip"])
    raw_avg = lambda r: r.get("raw_avg", r["predicted_avg"])
    sk = [r for r in rows if r.get("actual_skip") is not None]
    av = [r for r in rows if r.get("actual_avg") and raw_avg(r)]
    shift = statistics.median([r["actual_skip"] - raw_skip(r) for r in sk]) if sk else 0.0
    ratio = max(0.6, min(1.6, statistics.median([r["actual_avg"] / raw_avg(r) for r in av]))) if av else 1.0
    # The range is earned: as wide as this account's numbers scatter around the fitted estimate.
    spread = min(0.15, max(0.03, statistics.median([abs(r["actual_skip"] - raw_skip(r) - shift) for r in sk]))) if sk else SPREAD
    wide = min(1.6, max(1.08, math.exp(statistics.median(
        [abs(math.log(r["actual_avg"] / (raw_avg(r) * ratio))) for r in av])))) if av else WIDE
    return {"n": len(rows), "fitted": True, "skip_shift": shift, "watch_ratio": ratio, "spread": spread, "wide": wide}


def fit_multiplier(m, j, fit, **kw):
    """The hazard multiplier that puts average watch at the account's ratio times the unfitted estimate."""
    if not fit["fitted"]:
        return 1.0
    target = sum(curve(m, j, **kw)[0]) * fit["watch_ratio"]
    lo, hi = 0.1, 6.0
    for _ in range(30):
        mid = (lo + hi) / 2
        if sum(curve(m, j, skip_shift=fit["skip_shift"], hazard_mult=mid, **kw)[0]) > target:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2


def cmd_estimate(a):
    run_dir = Path(a.run)
    m = read_json(run_dir / "measure.json")
    if not m:
        die(f"no measure.json in {run_dir}: run measure first")
    j = check_judge(read_json(run_dir / "judge.json"))
    fit = account_fit(a.account)
    mult = fit_multiplier(m, j, fit)
    shift, d = fit["skip_shift"], m["duration"]
    samples, skip = curve(m, j, skip_shift=shift, hazard_mult=mult)
    main = summarize(samples, skip, d)
    raw = summarize(*curve(m, j), d)  # unfitted: what the log compares real numbers with
    lo, slo = curve(m, j, skip_shift=shift + fit["spread"], hazard_mult=mult * fit["wide"])
    hi, shi = curve(m, j, skip_shift=shift - fit["spread"], hazard_mult=mult / fit["wide"])
    low, high = summarize(lo, slo, d), summarize(hi, shi, d)

    def gain(**kw):
        s, sk = curve(m, j, skip_shift=shift, hazard_mult=mult, **kw)
        return summarize(s, sk, d)["score"] - main["score"]

    finds = []
    for f in measured_findings(m):
        finds.append({**f, "source": "measured", "t": f["from"], "why": DEFAULT_FIX.get(f["kind"], ""),
                      "gain": gain(drop=frozenset([f["id"]]))})
    for k in OPENING:
        o = j["opening"][k]
        if o["score"] < 2:
            finds.append({"id": f"opening.{k}", "kind": k, "source": "judged", "t": o.get("t") or 0.0,
                          "why": o.get("evidence", ""), "gain": gain(raise_to_two=frozenset([k]))})
    for k in ("payoff", "ending"):
        o = j[k]
        if o["score"] < 2:
            finds.append({"id": k, "kind": k, "source": "judged", "t": o.get("t") or 0.0,
                          "why": o.get("evidence", ""), "gain": gain(raise_to_two=frozenset([k]))})
    for i, s in enumerate(j["stale"]):
        finds.append({"id": f"stale@{i}", "kind": "stale", "source": "judged", "t": s["from"], "from": s["from"],
                      "to": s["to"], "why": s.get("why", ""), "gain": gain(drop=frozenset([f"stale@{i}"]))})
    for i, s in enumerate(j["spots"]):
        finds.append({"id": f"spot@{i}", "kind": "spot", "source": "judged", "t": s["t"],
                      "why": s.get("why", ""), "gain": gain(drop=frozenset([f"spot@{i}"]))})
    finds.sort(key=lambda f: -f["gain"])
    est = {**main, "raw_skip": raw["skip"], "raw_avg": raw["avg_s"],
           "score_low": min(low["score"], high["score"]), "score_high": max(low["score"], high["score"]),
           "duration": d, "curve": samples, "low": lo, "high": hi, "findings": finds,
           "account": a.account or "default", "fit": fit}
    write_json(run_dir / "estimate.json", est)
    print(f"Estimated score {est['score']} (likely range {est['score_low']}-{est['score_high']})  about "
          f"{main['skip'] * 100:.0f}% gone by 3 s  about {main['avg_s']} s average watch ({main['avg_share'] * 100:.0f}%)")
    print("Not fitted to this account yet." if not fit["fitted"] else f"Fitted on {fit['n']} reels.")
    print("Findings, most points first (write fixes.json for these ids; skip the +0 ones unless they are real):")
    for f in finds[:10]:
        print(f"  +{f['gain']:<3} {clock(f['t']):>5}  {f['id']}  [{f['source']}] {f.get('why', '')[:80]}")


# ------------------------------------------------------------------ report
def nearest_frame(run_dir, t):
    folder = run_dir / "frames"
    best, gap = None, 1e9
    for p in folder.iterdir() if folder.exists() else []:
        if p.suffix.lower() not in (".jpg", ".jpeg", ".png") or not p.stem.startswith("t"):
            continue
        try:
            at = float(p.stem[1:])
        except ValueError:  # not named by its second: no way to place it
            continue
        if abs(at - t) < gap:
            best, gap = p, abs(at - t)
    if not best:
        return ""
    mime = "image/png" if best.suffix.lower() == ".png" else "image/jpeg"
    return f"data:{mime};base64," + base64.b64encode(best.read_bytes()).decode()


def chart(est, m, j):
    W, H, L, R, T, B = 720, 300, 54, 16, 14, 30
    d = est["duration"]
    x = lambda t: L + (W - L - R) * t / d
    y = lambda v: T + (H - T - B) * (1 - v)
    line = lambda rows: " ".join(f"{x(i * 0.25):.1f},{y(v):.1f}" for i, v in enumerate(rows))
    parts = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="{html.escape(LABELS["chart"])}">']
    for v in (0, 0.25, 0.5, 0.75, 1):
        parts.append(f'<line x1="{L}" x2="{W - R}" y1="{y(v):.1f}" y2="{y(v):.1f}" class="grid"/>'
                     f'<text x="{L - 8}" y="{y(v) + 4:.1f}" class="axis" text-anchor="end">{int(v * 100)}%</text>')
    tick = next((s for s in (1, 2, 5, 10, 15, 30, 60) if d / s <= 6), 120)
    for i in range(int(d // tick) + 1):
        parts.append(f'<text x="{x(i * tick):.1f}" y="{H - 8}" class="axis" text-anchor="middle">{clock(i * tick)}</text>')
    for f in est["findings"]:
        if "from" in f and "to" in f and f["kind"] != "late_first_word":
            wide = max(2, x(f["to"]) - x(f["from"]))
            parts.append(f'<rect x="{x(f["from"]):.1f}" y="{T}" width="{wide:.1f}" height="{H - T - B}" class="zone"/>')
            if wide >= 34 and f["kind"] in LABELS["zones"]:
                parts.append(f'<text x="{x(f["from"]) + wide / 2:.1f}" y="{T + 14}" class="zlabel" text-anchor="middle">'
                             f'{html.escape(LABELS["zones"][f["kind"]])}</text>')
    if d > 3:
        parts.append(f'<line x1="{x(3):.1f}" x2="{x(3):.1f}" y1="{T}" y2="{H - B}" class="three"/>'
                     f'<text x="{x(3) + 5:.1f}" y="{H - B - 6}" class="axis">{html.escape(LABELS["three"])}</text>')
    n = len(est["low"])
    band = line(est["high"]) + " " + " ".join(f"{x(i * 0.25):.1f},{y(est['low'][i]):.1f}" for i in range(n - 1, -1, -1))
    parts.append(f'<polygon points="{band}" class="band"/><polyline points="{line(est["curve"])}" class="line"/>')
    p = j["payoff"].get("t")
    if p is not None and j["payoff"]["score"] > 0 and p < d:
        v = est["curve"][min(n - 1, int(p / 0.25))]
        parts.append(f'<circle cx="{x(p):.1f}" cy="{y(v):.1f}" r="4.5" class="dot"/>'
                     f'<text x="{x(p):.1f}" y="{y(v) - 10:.1f}" class="mark" text-anchor="middle">{html.escape(LABELS["payoff"])}</text>')
    parts.append("</svg>")
    return "".join(parts)


CSS = """
:root{--ink:#1b1830;--soft:#5d5a72;--line:#e6e3f1;--tint:#f5f3fc;--accent:#5a48e0;--warn:#d9822b}
*{box-sizing:border-box}body{margin:0;background:#fbfafe;color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:760px;margin:0 auto;padding:28px 18px 48px}header{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}
.file{font:12px ui-monospace,Consolas,monospace;color:var(--soft);overflow-wrap:anywhere}h1{font-size:20px;margin:4px 0 0}
.note{margin:6px 0 0;color:var(--soft);font-size:13px}
.score{text-align:right;flex:none}.score b{font-size:56px;line-height:1;color:var(--accent);letter-spacing:-.03em}
.score small{display:block;color:var(--soft);font-size:12px}.stats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:18px 0}
.stat{background:var(--tint);border:1px solid var(--line);border-radius:12px;padding:10px 14px}.stat span{display:block;color:var(--soft);font-size:12px}
.stat b{font-size:18px}.stat i{font-style:normal;color:var(--soft);font-size:14px}.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:14px}
.ctitle{font-weight:600;font-size:14px;margin:0 0 8px}.ctitle span{font-weight:400;color:var(--soft);font-size:12px;margin-left:6px}svg{display:block;width:100%;height:auto}
.grid{stroke:var(--line)}.axis{font-size:11px;fill:var(--soft)}.band{fill:var(--accent);opacity:.13}.line{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linejoin:round}
.zone{fill:var(--warn);opacity:.14}.zlabel{font-size:11px;font-weight:600;fill:#a35f17}.three{stroke:var(--soft);stroke-dasharray:3 4;opacity:.5}.dot{fill:var(--accent);stroke:#fff;stroke-width:2}.mark{font-size:11px;font-weight:600;fill:var(--ink)}
h2{font-size:15px;margin:22px 0 10px}.fix{display:grid;grid-template-columns:64px 1fr auto;gap:12px;align-items:center;background:#fff;border:1px solid var(--line);border-radius:12px;padding:10px;margin-bottom:8px}
.fix img{width:64px;height:88px;object-fit:cover;border-radius:7px;background:var(--tint)}.fix time{font:600 12px ui-monospace,Consolas,monospace;color:var(--accent)}
.fix p{margin:2px 0 0}.fix em{font-style:normal;color:var(--soft);font-size:12px;white-space:nowrap}.pnote{color:var(--soft);font-size:12.5px;margin:4px 0 0}
footer{margin-top:18px;color:var(--soft);font-size:12.5px}footer p{margin:6px 0}
@media(max-width:480px){.axis,.mark,.zlabel{font-size:21px}.line{stroke-width:4}.score b{font-size:44px}.fix{grid-template-columns:48px 1fr}.fix img{width:48px;height:66px}.fix em{grid-column:2}}
"""


def cmd_report(a):
    run_dir = Path(a.run)
    m, j, est = read_json(run_dir / "measure.json"), read_json(run_dir / "judge.json"), read_json(run_dir / "estimate.json")
    if not est:
        die("no estimate.json: run estimate first")
    check_judge(j, quiet=True)
    fixes = {f["id"]: f for f in read_json(run_dir / "fixes.json", [])}
    unknown = [i for i in fixes if i not in {f["id"] for f in est["findings"]}]
    if unknown:
        say(f"fixes.json names ids the estimate does not have: {', '.join(unknown)}")
    picked = [f for f in est["findings"] if f["id"] in fixes] or [f for f in est["findings"] if f["gain"] > 0]
    picked = sorted(picked, key=lambda f: -f["gain"])[:5]
    rows = []
    for f in picked:
        text = fixes.get(f["id"], {}).get("fix") or DEFAULT_FIX.get(f["kind"]) or f.get("why", "")
        t = fixes.get(f["id"], {}).get("t", f["t"])
        word = LABELS["gain_one"] if f["gain"] == 1 else LABELS["gain"]
        gain = f'<em>+{f["gain"]} {html.escape(word)}</em>' if f["gain"] > 0 else "<em></em>"
        rows.append(f'<div class="fix"><img src="{nearest_frame(run_dir, t)}" alt=""><div><time>{clock(t)}</time>'
                    f'<p>{html.escape(text)}</p></div>{gain}</div>')
    points = f'<p class="pnote">{html.escape(LABELS["points_note"])}</p>' if any(f["gain"] > 0 for f in picked) else ""
    fit = est["fit"]
    fit_line = LABELS["fit"].format(n=fit["n"]) if fit["fitted"] else LABELS["not_fit"].format(n=MIN_REELS_TO_FIT - fit["n"])
    name, about = html.escape(Path(str(m["file"])).name), html.escape(LABELS["about"])
    ran = dict(m.get("measured") or {}, words=m.get("words_measured", True))
    done = ", ".join(LABELS["probes"][k] for k in (*PROBES, "words") if ran.get(k, True))
    missed = ", ".join(LABELS["probes"][k] for k in (*PROBES, "words") if not ran.get(k, True))
    skipped = f" {html.escape(LABELS['skipped'].format(list=missed))}" if missed else ""
    page = f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(LABELS['title'])}: {name}</title><style>{CSS}</style></head><body><main>
<header><div><div class="file">{name} · {m['duration']:.1f} s</div><h1>{html.escape(LABELS['title'])}</h1>
<p class="note">{html.escape(LABELS['note'])}{skipped}</p></div>
<div class="score"><b>{est['score']}</b><small>{html.escape(LABELS['range'])} {est['score_low']}-{est['score_high']}</small></div></header>
<div class="stats"><div class="stat"><span>{html.escape(LABELS['skip'])}</span><i>{about}</i> <b>{est['skip'] * 100:.0f}%</b> {html.escape(LABELS['skip_sub'])}</div>
<div class="stat"><span>{html.escape(LABELS['avg'])}</span><i>{about}</i> <b>{est['avg_s']} s</b> ({est['avg_share'] * 100:.0f}%)</div></div>
<div class="card"><p class="ctitle">{html.escape(LABELS['chart'])}<span>{html.escape(LABELS['band'])}</span></p>{chart(est, m, j)}</div>
<h2>{html.escape(LABELS['fixes'])}</h2>{''.join(rows)}{points}
<footer><p>{html.escape(fit_line)}</p><p>{html.escape(LABELS['measured'].format(list=done))}{skipped} {html.escape(LABELS['how'])}</p></footer></main></body></html>"""
    out = run_dir / "report.html"
    out.write_text(page, encoding="utf-8")
    say(f"wrote {out} (it embeds the frames, so open it or hand over the path; don't read it back)")


# ------------------------------------------------------------------ log
def cmd_log(a):
    run_dir = Path(a.run)
    est = read_json(run_dir / "estimate.json")
    if not est:
        die("no estimate.json in that run")
    if a.skip is None and a.avg is None:
        die("give --skip (percent gone by 3 s) and/or --avg (average watch in seconds)")
    row = {"file": Path(str(read_json(run_dir / "measure.json")["file"])).name, "duration": est["duration"],
           "predicted_skip": est["skip"], "predicted_avg": est["avg_s"], "predicted_score": est["score"],
           "raw_skip": est.get("raw_skip", est["skip"]), "raw_avg": est.get("raw_avg", est["avg_s"]),
           "actual_skip": None if a.skip is None else a.skip / 100, "actual_avg": a.avg, "platform": a.platform}
    p = account_file(a.account or est.get("account"))
    p.parent.mkdir(parents=True, exist_ok=True)
    with p.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(row, ensure_ascii=False) + "\n")
    rows = account_rows(a.account or est.get("account"))
    skips = [abs(r["actual_skip"] - r["predicted_skip"]) * 100 for r in rows if r.get("actual_skip") is not None]
    avgs = [abs(r["actual_avg"] - r["predicted_avg"]) for r in rows if r.get("actual_avg")]
    say(f"logged to {p} ({len(rows)} reels)")
    if skips:
        print(f"  the estimated skip rate has been off by {statistics.median(skips):.0f} points (median)")
    if avgs:
        print(f"  the estimated average watch has been off by {statistics.median(avgs):.1f} s (median)")
    left = MIN_REELS_TO_FIT - len(rows)
    print(f"  {left} more and the estimate fits this account" if left > 0 else "  later runs on this account are fitted to these numbers")


def main():
    ap = argparse.ArgumentParser(description="Estimate how a short video will hold viewers.")
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("measure")
    p.add_argument("video", nargs="?")
    p.add_argument("--out", required=True)
    p.add_argument("--words", help="word timings: {w, s, e} in seconds, or a saved video_project read")
    p.add_argument("--model", default="small", help="speech-to-text model size")
    p.add_argument("--from-structure", help="a saved measurement file instead of running ffmpeg")
    p.set_defaults(fn=cmd_measure)
    p = sub.add_parser("estimate")
    p.add_argument("run")
    p.add_argument("--account", default="default")
    p.set_defaults(fn=cmd_estimate)
    p = sub.add_parser("report")
    p.add_argument("run")
    p.set_defaults(fn=cmd_report)
    p = sub.add_parser("log")
    p.add_argument("run")
    p.add_argument("--skip", type=float, help="percent gone by 3 s, as the platform reports it")
    p.add_argument("--avg", type=float, help="average watch in seconds, as the platform reports it")
    p.add_argument("--platform", default="")
    p.add_argument("--account")
    p.set_defaults(fn=cmd_log)
    a = ap.parse_args()
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    a.fn(a)


if __name__ == "__main__":
    main()
```
Show less

The prompt

PROMPT.md · 31 lines · 2 KB
Copy Raw file
# The prompt

First give your agent the watch-score skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in.

**Tip:** ffmpeg and Python are all this needs, the video stays on your machine, and no AI media is made. If your agent can't run ffmpeg, HYVIRAL (optional) is one way to measure a video through one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`).

---

Use the watch-score skill to check this video before it goes out.

- **The video:** [VIDEO - e.g. attached, or ~/Videos/reel-12.mp4]
- **Where it will go:** [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]
- **Who it's for:** [AUDIENCE - e.g. home cooks who want quick dinners]
- **What it should make them do:** [GOAL - e.g. save it and follow]
- **Account name for my history:** [ACCOUNT - e.g. my-main-channel]
- **Language of the report:** [LANGUAGE - e.g. English]

Work in this order:

1. Measure the file with the skill's script.
2. Look at the frames and read the transcript, then answer the skill's checklist as a stranger who has never seen this account, with a second and evidence for every answer. Judge the ending on what I want people to do.
3. Run the estimate, and use its numbers as they come.
4. Write up to five fixes, each with a second and the real words or shot involved, and make the report.
5. Give me the score, skip rate, average watch and fixes in chat, labeled as estimates, with the path to the report. Change nothing in the video.

After it's posted I'll send you the real numbers, so log them for this account.
Show less

Cookies for ads and referrals

We use the Meta Pixel to measure our ads and Tolt to credit partners who refer you. Accept to allow them; decline and the site works the same. Privacy Policy

Decline Accept