"""Seedance 2.5 prompts: what the most-liked ones have in common.

Reproduces https://agentcraft.dev/data/seedance-2-5-prompts-likes-2026-11.json from the public prompt collection
https://github.com/ai-boost/awesome-image-video-prompts (one YAML file per prompt) and each post's like count, read
from X's public embed endpoint (cdn.syndication.twimg.com), one request per second.

  git clone https://github.com/ai-boost/awesome-image-video-prompts
  git -C awesome-image-video-prompts checkout 590b532
  pip install pyyaml
  python3 seedance-2-5-prompts-likes-2026-11.py awesome-image-video-prompts likes-cache.json > out.json

Likes keep growing after a post goes up, so posts are ranked within their posting month: a prompt's percentile is
its rank by likes among Seedance 2.5 posts from the same month. "Top" is the top quarter by that percentile,
"bottom" the bottom quarter. Prompt text is not copied into the output; each row links to its YAML file.
"""
import datetime
import json
import re
import statistics
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

import yaml

REPO, CACHE = Path(sys.argv[1]), Path(sys.argv[2])
MODEL = "Seedance 2.5"

NUM = r"\d{1,3}(?:\.\d+)?"
SEG = re.compile(rf"(?<![\d.:]){NUM}\s*(?:s|sec|secs|seconds|秒)?\s*[-–—~]\s*{NUM}\s*(?:s|sec|secs|seconds|秒)(?![a-z])"
                 r"|\b\d{1,2}:\d{2}\s*[-–—~]\s*\d{1,2}:\d{2}\b", re.I)
SEG_END = re.compile(rf"(?<![\d.:]){NUM}\s*(?:s|sec|secs|seconds|秒)?\s*[-–—~]\s*({NUM})\s*(?:s|sec|secs|seconds|秒)(?![a-z])", re.I)
MMSS_END = re.compile(r"\b\d{1,2}:\d{2}\s*[-–—~]\s*(\d{1,2}):(\d{2})\b")
SHOT = re.compile(r"(?i)\b(?:shot|scene|cut|beat)\s*#?\d{1,2}\b|镜头\s*\d|分镜\s*\d|カット\s*\d")
DURATION = re.compile(rf"(?i)\b{NUM}\s*[- ]?(?:s|sec|secs|seconds?)\b(?!\s*[-–—~])|{NUM}\s*秒")
REF = re.compile(r"(?i)@\s?(?:image|video|audio)\s?\d*|\b(?:reference|input|uploaded)\s+(?:image|video|photo)|\bimage\s?\d\b|图\s?\d|参考图")
LABEL = re.compile(r"^\s*[\[【]?[\w一-鿿぀-ヿ /&()-]{2,30}[\]】]?\s*[:：|]|^\s*[【\[][^\]】]{2,20}[\]】]")
SPEECH = re.compile(r'(?i)(?:says?|said|asks?|whispers?|shouts?|yells?|replies|mutters|dialogue|line)\b[^"“\n]{0,40}["“]|台词|说[:：]|对白')
AUDIO = re.compile(r"(?i)\baudio\b|\bsounds?\b|\bsfx\b|\bambient\b|\bmusic\b|\bsoundtrack\b|\bvoice(?:over)?\b|音效|配乐|音乐|背景音")
NEGATIVE = re.compile(r"(?i)\b(no|don't|do not|avoid|without|never)\b|不要|避免|禁止|しない|なし")
RATIO = re.compile(r"(?i)\b(?:16|9|21|4|3|1)\s*:\s*(?:9|16|4|3|1)\b|aspect ratio|vertical|horizontal|竖屏|横屏")
ONE_TAKE = re.compile(r"(?i)\bone[- ](?:take|shot)\b|\bsingle (?:continuous )?(?:take|shot)\b|\bcontinuous (?:take|shot)\b|\blong take\b|\boner\b|\bno cuts?\b|一镜到底|长镜头")
STYLE = {
    "cinematic": r"(?i)\bcinematic\b",
    "photorealistic / realistic": r"(?i)\bphoto-?realistic\b|\bhyper-?realistic\b|\brealistic\b",
    "4K / 8K": r"(?i)\b[48]k\b",
    "IMAX / film stock": r"(?i)\bimax\b|\b35\s?mm\b|\bfilm grain\b|\banamorphic\b",
    "anime": r"(?i)\banime\b",
    "slow motion": r"(?i)\bslow[- ]?mo(?:tion)?\b",
}
CAMERA = {
    "close-up": r"(?i)close[- ]?ups?\b", "tracking": r"(?i)\btracking\b|camera\s+tracks\b", "wide shot": r"(?i)\bwide\s+shots?\b",
    "handheld": r"(?i)\bhand[- ]?held\b", "dolly": r"(?i)\bdolly\b", "push-in": r"(?i)\bpush(?:es|ing)?[- ]in\b",
    "pull back": r"(?i)\bpull(?:s|ing)?[- ]?(?:back|out)\b", "orbit": r"(?i)\borbit", "pan": r"(?i)\bpan(?:s|ning)?\b",
    "tilt": r"(?i)\btilt", "crane": r"(?i)\bcrane\b", "aerial / drone": r"(?i)\baerial\b|\bdrone\b", "POV": r"(?i)\bpov\b|point[- ]of[- ]view",
    "FPV": r"(?i)\bfpv\b", "zoom": r"(?i)\bzoom", "static": r"(?i)\bstatic (?:shot|camera)|locked[- ]off", "low angle": r"(?i)\blow[- ]angle\b",
    "medium shot": r"(?i)\bmedium\s+shots?\b", "whip pan": r"(?i)\bwhip[- ]?pan", "over-the-shoulder": r"(?i)over[- ]the[- ]shoulder",
    "macro": r"(?i)\bmacro\b", "one take": ONE_TAKE.pattern,
}


def git(*args):
    return subprocess.run(["git", "-C", str(REPO), *args], capture_output=True, text=True, check=True).stdout


def lang(text):
    if re.search(r"[぀-ヿ]", text):
        return "ja"
    if re.search(r"[가-힯]", text):
        return "ko"
    if len(re.findall(r"[一-鿿]", text)) > 0.2 * len(re.findall(r"[A-Za-z一-鿿]", text)):
        return "zh"
    return "en"


def posted(url):
    m = re.search(r"/status/(\d+)", url or "")
    if not m:
        return None, None
    ms = (int(m.group(1)) >> 22) + 1288834974657
    return m.group(1), datetime.datetime.fromtimestamp(ms / 1000, datetime.timezone.utc).date()


def likes(ids):
    """Current like count per X post id, cached in CACHE so reruns only fetch what is missing."""
    cache = json.loads(CACHE.read_text()) if CACHE.exists() else {}
    for i in [i for i in ids if i not in cache]:
        url = f"https://cdn.syndication.twimg.com/tweet-result?id={i}&token=a"
        for wait in (0, 5, 20):
            time.sleep(wait)
            try:
                d = json.loads(urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}), timeout=20).read())
                cache[i] = {"likes": d.get("favorite_count")}
                break
            except urllib.error.HTTPError as err:
                if err.code == 404:
                    cache[i] = {"likes": None}
                    break
            except Exception:
                pass
        time.sleep(1)
    CACHE.write_text(json.dumps(cache))
    return {i: v.get("likes") for i, v in cache.items()}


def row(path, e):
    p = e["prompt"].strip()
    lines = [l for l in p.split("\n") if l.strip()]
    pid, day = posted(e["source"].get("url"))
    segs = len(SEG.findall(p))
    ends = [float(m.group(1)) for m in SEG_END.finditer(p)] + [int(a) * 60 + int(b) for a, b in MMSS_END.findall(p)]
    shots = len(set(m.group(0).lower().replace(" ", "") for m in SHOT.finditer(p)))
    labeled = sum(1 for l in lines if LABEL.match(l)) >= 2
    structure = ("json" if p.lstrip().startswith("{") else "timed shot list" if segs >= 2 else
                 "numbered shots" if shots >= 2 else "labeled blocks" if labeled else "prose")
    r = {
        "id": e["id"], "postId": pid, "posted": str(day) if day else None, "month": str(day)[:7] if day else None,
        "author": e["source"].get("credit") or e["source"].get("name"), "mode": e.get("mode"), "category": e.get("category"), "lang": lang(p),
        "chars": len(p), "words": len(p.split()) if lang(p) == "en" else None, "lines": len(lines),
        "structure": structure, "timedSegments": segs, "timed": segs >= 2,
        "plannedSeconds": max(ends) if segs >= 2 and ends else None,
        "multiShot": segs >= 2 or shots >= 2, "duration": bool(DURATION.search(p)), "oneTake": bool(ONE_TAKE.search(p)),
        "references": bool(REF.search(p)), "labeledSections": labeled, "speech": bool(SPEECH.search(p)),
        "audio": bool(AUDIO.search(p)), "negative": bool(NEGATIVE.search(p)), "aspectRatio": bool(RATIO.search(p)),
        "camera": sorted(k for k, v in CAMERA.items() if re.search(v, p)),
        "style": sorted(k for k, v in STYLE.items() if re.search(v, p)),
        "file": f"https://github.com/ai-boost/awesome-image-video-prompts/blob/main/{path}",
    }
    r["cameraTerms"] = len(r["camera"])
    return r


def summary(rows):
    n = len(rows)
    if not n:
        return {"n": 0}
    pct = lambda f: round(100 * sum(1 for r in rows if f(r)) / n)
    med = lambda k: statistics.median([r[k] for r in rows if r[k] is not None]) if any(r[k] is not None for r in rows) else None
    out = {"n": n, "authors": len({r["author"] for r in rows}), "medianLikes": med("likes"),
           "medianChars": med("chars"), "medianLines": med("lines"), "medianCameraTerms": med("cameraTerms"),
           "medianPlannedSeconds": med("plannedSeconds"),
           "structure": {s: pct(lambda r, s=s: r["structure"] == s) for s in ["timed shot list", "numbered shots", "labeled blocks", "prose", "json"]},
           "pctReferenceMode": pct(lambda r: r["mode"] == "reference-to-video"),
           "categories": {c: sum(1 for r in rows if r["category"] == c) for c in sorted({r["category"] for r in rows})}}
    for k in ["timed", "multiShot", "duration", "oneTake", "references", "labeledSections", "speech", "audio", "negative", "aspectRatio"]:
        out["pct" + k[0].upper() + k[1:]] = pct(lambda r, k=k: r[k])
    out["camera"] = {c: pct(lambda r, c=c: c in r["camera"]) for c in CAMERA}
    out["style"] = {s: pct(lambda r, s=s: s in r["style"]) for s in STYLE}
    return out


def main():
    commit = git("rev-parse", "--short", "HEAD").strip()
    rows = []
    for path in sorted(git("ls-tree", "--name-only", "HEAD", "prompts/video/").split()):
        e = yaml.safe_load(git("show", f"HEAD:{path}"))
        if e.get("model") == MODEL and "x.com/" in e.get("source", {}).get("url", ""):
            rows.append(row(path, e))
    got = likes([r["postId"] for r in rows])
    for r in rows:
        r["likes"] = got.get(r["postId"])
    rows = [r for r in rows if r["likes"] is not None]

    # Percentile within posting month, so older posts do not win just by having had longer to collect likes.
    for m in {r["month"] for r in rows}:
        group = sorted((r for r in rows if r["month"] == m), key=lambda r: r["likes"])
        for i, r in enumerate(group):
            r["monthPercentile"] = round(100 * i / max(len(group) - 1, 1))
    top = [r for r in rows if r["monthPercentile"] >= 75]
    bottom = [r for r in rows if r["monthPercentile"] <= 25]

    # One prompt per author (their most-liked), so a few prolific accounts do not set the result.
    best = {}
    for r in rows:
        if r["author"] not in best or r["monthPercentile"] > best[r["author"]]["monthPercentile"]:
            best[r["author"]] = r
    per_author = list(best.values())

    groups = {
        "all": summary(rows),
        "top quarter (within month)": summary(top),
        "bottom quarter (within month)": summary(bottom),
        "top 10% (within month)": summary([r for r in rows if r["monthPercentile"] >= 90]),
        "one per author, top half": summary([r for r in per_author if r["monthPercentile"] >= 50]),
        "one per author, bottom half": summary([r for r in per_author if r["monthPercentile"] < 50]),
        "outside vlogs, top quarter": summary([r for r in top if r["category"] != "vlog"]),
        "outside vlogs, bottom quarter": summary([r for r in bottom if r["category"] != "vlog"]),
        "English text-to-video, top quarter": summary([r for r in top if r["lang"] == "en" and r["mode"] == "text-to-video"]),
        "English text-to-video, bottom quarter": summary([r for r in bottom if r["lang"] == "en" and r["mode"] == "text-to-video"]),
    }
    by_month = {m: {"n": sum(1 for r in rows if r["month"] == m),
                    "medianLikes": statistics.median([r["likes"] for r in rows if r["month"] == m])} for m in sorted({r["month"] for r in rows})}
    json.dump({
        "title": "Seedance 2.5 prompts and their likes: per-prompt features",
        "source": f"https://github.com/ai-boost/awesome-image-video-prompts/tree/{commit}",
        "page": "https://agentcraft.dev/en/blog/seedance-2-5-prompts-likes-2026-11/",
        "likesFetched": str(datetime.date.today()),
        "definitions": {
            "monthPercentile": "rank by likes among Seedance 2.5 posts from the same month, 0 to 100",
            "structure": "timed shot list (two or more time ranges), numbered shots (Shot 1, Scene 2...), labeled blocks (two or more Label: lines), prose, or json",
            "plannedSeconds": "for a timed prompt, the latest end of any time range",
            "oneTake": "asks for one continuous take: one take, single shot, long take, no cuts",
            "camera": "camera moves and framings from a fixed list, counted once per prompt",
            "style": "style words from a fixed list: cinematic, photorealistic, 4K, IMAX or film stock, anime, slow motion",
        },
        "byMonth": by_month,
        "groups": groups,
        "rows": rows,
    }, sys.stdout, ensure_ascii=False, indent=1)


if __name__ == "__main__":
    main()
