File size: 7,908 Bytes
e488ce7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
#!/usr/bin/env python3
"""Starter manifest for build_compare_page.py, from a directory of mp4s.

Point it at a folder of clips and it writes the boring half of a manifest:
one row per clip, arm from the filename stem, src (and poster) paths, plus
fps, duration and aspect probed with ffprobe. Metrics merge in by stem from
either a directory of one-JSON-per-clip or a single CSV.

It does NOT invent groups or descriptions. Both come out as empty strings
for a human to fill, because the grouping is the argument the deck is
making and a guessed one is worse than a blank.

  python3 make_manifest.py CLIPS_DIR manifest.json \\
      --title "de-rope block sweep" \\
      --posters posters/ --metrics metrics.csv \\
      --src-prefix media/derope --poster-prefix posters

Paths in the manifest are `<prefix>/<filename>`. The built page resolves
them relative to the HTML file, so pass the prefix as the page will see it
(default: the CLIPS_DIR / --posters values exactly as typed).
"""
import argparse, csv, json, os, subprocess, sys


def probe(path):
    """(fps, duration_s, width, height) for one file, via ffprobe."""
    cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
           "-show_entries", "stream=r_frame_rate,width,height",
           "-show_entries", "format=duration", "-of", "json", path]
    try:
        raw = subprocess.run(cmd, capture_output=True, text=True,
                             check=True).stdout
    except FileNotFoundError:
        sys.exit("ffprobe not found on PATH; it is required")
    except subprocess.CalledProcessError as e:
        sys.exit("ffprobe failed on %s: %s" % (path, e.stderr.strip()))
    d = json.loads(raw)
    if not d.get("streams"):
        sys.exit("no video stream in %s" % path)
    st = d["streams"][0]
    num, _, den = st.get("r_frame_rate", "0/1").partition("/")
    fps = float(num) / float(den or 1) if float(den or 1) else 0.0
    dur = float(d.get("format", {}).get("duration") or 0.0)
    return fps, dur, int(st.get("width") or 0), int(st.get("height") or 0)


def load_metrics(path, key_col):
    """{stem: {metric: value}} from a CSV file or a directory of JSONs."""
    out = {}
    if os.path.isdir(path):
        for f in sorted(os.listdir(path)):
            if not f.endswith(".json"):
                continue
            with open(os.path.join(path, f), encoding="utf-8") as fh:
                d = json.load(fh)
            if not isinstance(d, dict):
                sys.exit("%s is not a flat JSON object" % os.path.join(path, f))
            out[os.path.splitext(f)[0]] = d
        return out
    with open(path, newline="", encoding="utf-8") as fh:
        rd = csv.DictReader(fh)
        if rd.fieldnames is None:
            sys.exit("%s has no header row" % path)
        kc = key_col or rd.fieldnames[0]
        if kc not in rd.fieldnames:
            sys.exit("column %r not in %s (has %s)"
                     % (kc, path, ", ".join(rd.fieldnames)))
        for r in rd:
            stem = os.path.splitext(str(r[kc]).strip())[0]
            out[stem] = {k: v for k, v in r.items() if k != kc}
    return out


def coerce(v):
    """CSV and JSON both arrive stringly; numbers sort and bar, strings do not."""
    if isinstance(v, (int, float)) or v is None:
        return v
    s = str(v).strip()
    if s == "":
        return None
    try:
        return int(s) if s.lstrip("+-").isdigit() else float(s)
    except ValueError:
        return s


def main():
    p = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("clips_dir", help="directory of clips (probed, not copied)")
    p.add_argument("out_json", help="manifest to write")
    p.add_argument("--glob-ext", default=".mp4",
                   help="clip extension to pick up (default .mp4)")
    p.add_argument("--posters", metavar="DIR",
                   help="directory of posters; matched by stem, .jpg then .png")
    p.add_argument("--metrics", metavar="PATH",
                   help="a CSV file, or a directory of one flat JSON per clip; "
                        "merged onto rows by filename stem")
    p.add_argument("--metrics-key", metavar="COL",
                   help="CSV column holding the clip stem (default: first column)")
    p.add_argument("--src-prefix", metavar="P",
                   help="path prefix for src in the page (default: CLIPS_DIR)")
    p.add_argument("--poster-prefix", metavar="P",
                   help="path prefix for poster in the page (default: --posters)")
    p.add_argument("--title", default="compare deck", help="page title")
    p.add_argument("--h1", help="page heading (default: --title)")
    p.add_argument("--lede-html", default="",
                   help="one paragraph of intro HTML, verbatim")
    p.add_argument("--fps", type=float,
                   help="force the page clock instead of using the probed fps")
    p.add_argument("--aspect",
                   help='force aspect, e.g. "16/9" (default: probed from the '
                        "first clip)")
    p.add_argument("--force", action="store_true",
                   help="overwrite out_json if it exists")
    a = p.parse_args()

    if os.path.exists(a.out_json) and not a.force:
        sys.exit("%s exists; pass --force to overwrite" % a.out_json)
    if not os.path.isdir(a.clips_dir):
        sys.exit("%s is not a directory" % a.clips_dir)
    names = sorted(f for f in os.listdir(a.clips_dir) if f.endswith(a.glob_ext))
    if not names:
        sys.exit("no %s files in %s" % (a.glob_ext, a.clips_dir))

    metrics = load_metrics(a.metrics, a.metrics_key) if a.metrics else {}
    src_prefix = a.clips_dir if a.src_prefix is None else a.src_prefix
    poster_prefix = a.posters if a.poster_prefix is None else a.poster_prefix

    rows, fps_seen, aspect, mkeys = [], [], None, []
    for name in names:
        stem = os.path.splitext(name)[0]
        fps, dur, w, h = probe(os.path.join(a.clips_dir, name))
        fps_seen.append(fps)
        if aspect is None and w and h:
            aspect = "%d/%d" % (w, h)
        row = {"arm": stem, "group": "", "desc": "",
               "src": os.path.join(src_prefix, name).replace(os.sep, "/"),
               "dur_s": round(dur, 3)}
        if a.posters:
            for ext in (".jpg", ".png"):
                if os.path.exists(os.path.join(a.posters, stem + ext)):
                    row["poster"] = os.path.join(
                        poster_prefix, stem + ext).replace(os.sep, "/")
                    break
            else:
                print("no poster for %s" % stem, file=sys.stderr)
        for k, v in (metrics.get(stem) or {}).items():
            row[k] = coerce(v)
            if k not in mkeys:
                mkeys.append(k)
        if metrics and stem not in metrics:
            print("no metrics row for %s" % stem, file=sys.stderr)
        rows.append(row)

    uniq = sorted(set(round(f, 4) for f in fps_seen if f))
    if a.fps is None and len(uniq) > 1:
        sys.exit("clips disagree on fps (%s); pass --fps to pick one"
                 % ", ".join(str(u) for u in uniq))
    fps = a.fps if a.fps is not None else (uniq[0] if uniq else 24)

    cols = [["dur_s", "duration (s)", 2, 1]]
    cols += [[k, k, 2, 1] for k in mkeys]
    man = {"title": a.title, "h1": a.h1 or a.title, "lede_html": a.lede_html,
           "aspect": a.aspect or aspect or "16/9",
           "fps": int(fps) if float(fps).is_integer() else fps,
           "media": "", "cols": cols, "groups": [], "rows": rows}
    with open(a.out_json, "w", encoding="utf-8") as fh:
        json.dump(man, fh, indent=1)
        fh.write("\n")
    print("wrote %s (%d rows, fps %s, aspect %s, %d metric cols); "
          "fill in group and desc before building"
          % (a.out_json, len(rows), man["fps"], man["aspect"], len(mkeys)))


if __name__ == "__main__":
    main()