dramapack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dramapack/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """dramapack — delivery packs, mastering, acceptance audits and rights checks for
2
+ short dramas / vertical dramas / micro dramas.
3
+
4
+ Built and used in production by Dawnwood, an AI drama studio (https://dawnwood.ai).
5
+ """
6
+ from .provenance import PROVENANCE, __version__, footer_line
7
+
8
+ __all__ = ["PROVENANCE", "__version__", "footer_line"]
dramapack/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
dramapack/api.py ADDED
@@ -0,0 +1,152 @@
1
+ """Programmatic entry points that return JSON-ready dicts.
2
+
3
+ Shared by the CLI (``--json``) and the MCP server, so humans, scripts and agents all get
4
+ the same result shape. Every result has ``"ok"`` and, where grading applies, a
5
+ ``"status"`` of ``"green" | "yellow" | "red"``. Paths in results are absolute.
6
+ """
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+
11
+ from . import master as M
12
+ from .ff import ffmpeg_exe, have_ffmpeg, probe_duration, probe_spec
13
+ from .provenance import PROVENANCE
14
+
15
+ _ORDER = {"green": 0, "yellow": 1, "red": 2}
16
+
17
+
18
+ def worst(levels) -> str:
19
+ levels = [x for x in levels if x in _ORDER]
20
+ return max(levels, key=_ORDER.get) if levels else "green"
21
+
22
+
23
+ def _abs(p):
24
+ return os.path.abspath(os.path.expanduser(p)) if p else p
25
+
26
+
27
+ def _require_ffmpeg():
28
+ if not have_ffmpeg():
29
+ raise RuntimeError("ffmpeg not found: install ffmpeg (with libass + libx264) or set "
30
+ "DRAMAPACK_FFMPEG / DRAMAPACK_FFPROBE")
31
+
32
+
33
+ def inspect_file(path: str) -> dict:
34
+ """Measured specs of a media file plus integrated loudness and true peak."""
35
+ _require_ffmpeg()
36
+ path = _abs(path)
37
+ if not os.path.exists(path):
38
+ raise FileNotFoundError(path)
39
+ exe = ffmpeg_exe()
40
+ spec = probe_spec(exe, path)
41
+ loud = M.measure_loudness(exe, path) or {}
42
+
43
+ def num(k):
44
+ try:
45
+ v = float(loud[k])
46
+ except (KeyError, TypeError, ValueError):
47
+ return None
48
+ return v if v > -70 else None
49
+
50
+ lufs = num("input_i")
51
+ return {"ok": True, "path": path, **spec,
52
+ "integrated_lufs": lufs, "true_peak_dbtp": num("input_tp"),
53
+ "loudness_range_lu": num("input_lra"),
54
+ "loudness_target_lufs": float(M.TARGET_LUFS),
55
+ "loudness_gain_to_target_db": M.loudness_gain_db(lufs) if lufs is not None else None}
56
+
57
+
58
+ def master_episode(input: str, output: str, *, srt: str | None = None, burn: bool = True,
59
+ bed: str | None = None, music: str | None = None,
60
+ music_db: float = M.MUSIC_DB, head_trim: float = 0.0,
61
+ end_card: dict | None = None, card_secs: float = M.CARD_SECS,
62
+ cover_band: float = 0.0, subtitle_font: str = M.SUB_FONT,
63
+ normalize: bool = True) -> dict:
64
+ """Master one episode; returns the output path and its measured specs."""
65
+ _require_ffmpeg()
66
+ input, output, srt, music = _abs(input), _abs(output), _abs(srt), _abs(music)
67
+ if bed and bed not in M.BED_PRESETS:
68
+ raise ValueError(f"unknown bed mood {bed!r}; choose from {', '.join(M.BED_MOODS)}")
69
+ exe = ffmpeg_exe()
70
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
71
+ with tempfile.TemporaryDirectory(prefix="dramapack-") as tmp:
72
+ if bed:
73
+ total = probe_duration(exe, input) - head_trim + (card_secs if end_card else 0.0)
74
+ music = M.render_bed(exe, max(1.0, total), os.path.join(tmp, "bed.wav"), bed)
75
+ staged = os.path.join(tmp, "out" + os.path.splitext(output)[1])
76
+ M.master(input, srt, staged, burn=burn, music=music, music_db=music_db, exe=exe,
77
+ head_trim=head_trim, end_card=end_card, card_secs=card_secs,
78
+ normalize=normalize, cover_band=cover_band, subtitle_font=subtitle_font)
79
+ shutil.move(staged, output)
80
+ return {"ok": True, "output": output, "subtitles": "burned" if burn and srt else "none",
81
+ "music_bed": bed, "music_file": music if music and not bed else None,
82
+ "spec": probe_spec(exe, output), "generator": dict(PROVENANCE)}
83
+
84
+
85
+ def build_package(input_dir: str, output_dir: str, *, show: str | None = None,
86
+ rights: str | None = None, title: str | None = None,
87
+ studio: str | None = None, language: str | None = None,
88
+ bed: str | None = None, music: str | None = None,
89
+ masters_ready: bool = False, episodes=None, platform_pairs: bool = False,
90
+ clean: bool = True, covers: bool = True, timeline: bool = True,
91
+ keep_work: bool = False) -> dict:
92
+ """Build a delivery pack; returns a summary plus the path to the full manifest."""
93
+ _require_ffmpeg()
94
+ from .package import build, load_show
95
+ input_dir, output_dir = _abs(input_dir), _abs(output_dir)
96
+ shw = load_show(input_dir, _abs(show), title=title, studio=studio, language=language,
97
+ music_bed=bed, music=_abs(music))
98
+ man = build(input_dir, output_dir, show=shw, rights_path=_abs(rights),
99
+ masters_ready=masters_ready, clean=clean, covers=covers, timeline=timeline,
100
+ platform_pairs=platform_pairs, episodes=episodes, keep_work=keep_work)
101
+ blocking = [g for g in man["gaps"] if "do not ship" in g or "failed" in g]
102
+ status = worst([man["rights"]["status"], "red" if blocking else
103
+ ("yellow" if man["gaps"] else "green")])
104
+ eps = [{"episode": e["episode"],
105
+ "files": {k: os.path.join(output_dir, v["path"]) for k, v in e["files"].items()
106
+ if isinstance(v, dict) and v.get("path")}}
107
+ for e in man["episodes"]]
108
+ return {"ok": True, "status": status, "output_dir": output_dir,
109
+ "manifest": os.path.join(output_dir, "manifest.json"),
110
+ "rights_status": man["rights"]["status"], "rights_summary": man["rights"]["summary"],
111
+ "gaps": man["gaps"], "notes": man["notes"], "episodes": eps,
112
+ "platform": [p["files"]["episode"]["path"] for p in man["platform"]],
113
+ "generator": man["generator"]}
114
+
115
+
116
+ def audit_files(files, *, cut: str | None = None, bounds=None, speech: bool = True,
117
+ hook_plugin: str | None = None) -> dict:
118
+ """Grade files against short-drama benchmarks."""
119
+ _require_ffmpeg()
120
+ from . import audit as A
121
+ files = [_abs(f) for f in ([files] if isinstance(files, str) else files)]
122
+ hook = A.load_hook_plugin(hook_plugin) if hook_plugin else None
123
+ skipped = []
124
+ if speech and not A.have_speech():
125
+ speech = False
126
+ skipped.append("speech metrics (pip install 'dramapack[speech]')")
127
+ if cut:
128
+ if len(files) != 1:
129
+ raise ValueError("cut pairs with exactly one master file")
130
+ rows = A.audit_pair(files[0], _abs(cut), do_speech=speech, bounds=bounds, hook_plugin=hook)
131
+ else:
132
+ rows = [A.audit(p, do_speech=speech, bounds=bounds, hook_plugin=hook) for p in files]
133
+ status = worst(g for r in rows for g in r["grades"].values())
134
+ return {"ok": True, "status": status, "rows": rows, "skipped": skipped,
135
+ "thresholds": {k: {"green": v[0], "red": v[1]} for k, v in A.T.items()}}
136
+
137
+
138
+ def check_rights(rights: str | None = None, *, input_dir: str | None = None,
139
+ bed: str | None = None, music: str | None = None,
140
+ subtitle_font: str | None = M.SUB_FONT, end_card: bool = False,
141
+ allow_fonts=()) -> dict:
142
+ """Grade declared music and burned-in fonts."""
143
+ from .rights import check_rights as _check
144
+ input_dir = _abs(input_dir)
145
+ rights = _abs(rights)
146
+ if not rights and input_dir and os.path.exists(os.path.join(input_dir, "rights.json")):
147
+ rights = os.path.join(input_dir, "rights.json")
148
+ res = _check(rights, input_dir=input_dir, bed_mood=bed, external_music=_abs(music),
149
+ subtitle_font=subtitle_font,
150
+ card_font_path=M.card_font_path() if end_card else None,
151
+ extra_allow_fonts=allow_fonts or ())
152
+ return {"ok": True, "rights_path": rights, **res}
dramapack/audit.py ADDED
@@ -0,0 +1,294 @@
1
+ """Post-render acceptance audit — grade a finished episode against short-drama market
2
+ benchmarks: green / yellow / red per metric.
3
+
4
+ It measures the things a producer or platform reviewer notices first. Thresholds come
5
+ from a measured benchmark sample (competing vertical dramas from platforms such as
6
+ DramaWave and ReelShort, other AI-produced series, and our own early cuts).
7
+
8
+ Deterministic metrics (ffmpeg only): duration, average shot length, long-shot share,
9
+ mean volume, seam level jumps (when splice points are given), frozen tail.
10
+ Optional: speech density via faster-whisper (``pip install dramapack[speech]``);
11
+ skipped automatically when not installed.
12
+ Optional and **off by default**: a hook-window check through a plugin you supply
13
+ (``--hook-plugin module:function``), e.g. a vision model that verifies the first frame
14
+ shows a person. No model, network access or API key is required by this package.
15
+
16
+ **Measure cuts and masters for different things.** The pacing/speech/seam baselines were
17
+ calibrated on raw cuts. A master's music bed hides seams and tightens the speech VAD
18
+ (the same episode measured speech_share 0.86 on the cut and 0.41 on the master — a false
19
+ red). So when both are available, grade the cut on the baseline metrics and the master
20
+ only on what exists only in the master: duration (incl. end card), first line (incl. head
21
+ trim), frozen tail, hook window.
22
+ """
23
+ import importlib
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import sys
28
+
29
+ from .ff import ffmpeg_exe, ffprobe_exe
30
+
31
+ # Every subprocess gets a timeout: a truncated mp4 can make ffmpeg read forever and hang
32
+ # an unattended acceptance job that otherwise just looks "still running".
33
+ FF_TIMEOUT_SHORT = 60 # single-window volumedetect / ffprobe
34
+ FF_TIMEOUT_LONG = 900 # whole-file scene scan / whole-file volume
35
+
36
+ #: Thresholds: (green bound, red bound). Direction is inferred: green < red means lower is
37
+ #: better; green > red means higher is better.
38
+ T = {
39
+ # Duration. TikTok Creator Rewards requires videos **longer than one minute** — a 59 s
40
+ # episode earns nothing even at a million views, and the algorithm weighs total watch
41
+ # time, not only completion. A sample of 131 same-niche competitors had a median of
42
+ # 85 s (serialized episode posts: 339 s); 55–75 s is on the short side, so there is no
43
+ # reason to cut shorter. Green at ≥ 65 s leaves margin — platforms judge on their own
44
+ # decoded duration. Higher is better.
45
+ "dur": (65.0, 60.0),
46
+ # Pacing. Live-action platform series: ASL 1.08–1.54 s; other AI productions 2.6–3.1;
47
+ # our early AI cuts 3.18. Lower is better.
48
+ "asl": (2.0, 3.0),
49
+ "long_share": (0.30, 0.50), # share of runtime in shots > 4 s; live-action series 0.23
50
+ # Dialogue density — the biggest gap measured. Competitors' speech_share 0.68–0.98,
51
+ # median 0.90; WPM 67–134. Higher is better (Chinese counted in characters, scaled 3/5).
52
+ "speech_share": (0.80, 0.65),
53
+ "wpm": (90, 70),
54
+ "max_gap": (5.0, 8.0), # longest stretch without dialogue (s), lower is better
55
+ # Loudness. Competitors' mean_volume −16.0 to −21.5 dB; early masters −24.7 to −27.4.
56
+ "mean_volume": (-20.0, -23.0), # higher is better
57
+ # Level jump at splice points. Independently rendered clips concatenated directly turn
58
+ # every splice into a room-tone/ambience jump — the ear catches it before any metric
59
+ # (one splice measured 40.8 dB). A continuous bed pulls it down (one episode: mean
60
+ # 11.8 → 8.2 dB). Lower is better.
61
+ "seam_db": (6.0, 15.0),
62
+ # First line of dialogue. The hook line should land immediately — swipe decisions
63
+ # happen in the first 3 seconds. Lower is better.
64
+ "first_speech": (1.5, 3.0),
65
+ # Frozen seconds at the end (end card + any freeze). Retention curves showed a 7.5 s
66
+ # card (12% of a 61 s episode) dropping end retention from r75 = 58% to r100 = 16%;
67
+ # episodes without a card kept 30–43%. Viewers swipe at a static card, logging an
68
+ # incomplete view at 88% for everyone. Green ≤ 5 s fits a 4 s card plus fade; red
69
+ # > 6.5 s catches long cards. Lower is better.
70
+ "tail_static": (5.0, 6.5),
71
+ }
72
+ SCENE_TH = 0.15 # low threshold catches in-scene cuts; a high one only sees scene changes
73
+ LONG_SHOT = 4.0
74
+ MASTER_KEYS = {"dur", "first_speech", "tail_static", "hook"}
75
+ _CJK = re.compile(r"[\u3400-\u9fff]")
76
+
77
+ GREEN, YELLOW, RED = "green", "yellow", "red"
78
+
79
+
80
+ def grade(key: str, val) -> str:
81
+ if val is None:
82
+ return "-"
83
+ green, red = T[key]
84
+ if green < red: # lower is better
85
+ return GREEN if val <= green else (RED if val > red else YELLOW)
86
+ return GREEN if val >= green else (RED if val < red else YELLOW)
87
+
88
+
89
+ def _rms(exe: str, path: str, a: float, b: float):
90
+ log = subprocess.run([exe, "-nostats", "-ss", f"{a:.3f}", "-to", f"{b:.3f}", "-i", path,
91
+ "-af", "volumedetect", "-f", "null", "-"],
92
+ capture_output=True, text=True, timeout=FF_TIMEOUT_SHORT).stderr
93
+ m = re.search(r"mean_volume: (-?[\d.]+)", log)
94
+ return float(m.group(1)) if m else None
95
+
96
+
97
+ def seam_jump(exe: str, path: str, bounds: list) -> dict:
98
+ """Level difference across each splice point (0.3 s either side) — measures "you can
99
+ hear the seam". ``bounds`` = cumulative seconds of each splice. Without bounds there is
100
+ nothing to measure: guessing splice points from the finished file mistakes normal
101
+ emotional dynamics for seams."""
102
+ jumps = []
103
+ for t in bounds:
104
+ lo = _rms(exe, path, max(0.0, t - 0.35), max(0.05, t - 0.05))
105
+ hi = _rms(exe, path, t + 0.05, t + 0.35)
106
+ if lo is not None and hi is not None:
107
+ jumps.append(abs(lo - hi))
108
+ if not jumps:
109
+ return {}
110
+ return {"seam_db": round(sum(jumps) / len(jumps), 1),
111
+ "seam_max": round(max(jumps), 1), "seams": len(jumps)}
112
+
113
+
114
+ def cut_metrics(exe: str, path: str) -> dict:
115
+ dur = float(subprocess.run(
116
+ [ffprobe_exe(), "-v", "error", "-show_entries", "format=duration",
117
+ "-of", "csv=p=0", path], capture_output=True, text=True,
118
+ timeout=FF_TIMEOUT_SHORT).stdout.strip() or 0)
119
+ # NB: metadata=print:file=- writes to STDOUT, not stderr.
120
+ r = subprocess.run([exe, "-nostats", "-i", path, "-filter:v",
121
+ f"select='gt(scene,{SCENE_TH})',metadata=print:file=-",
122
+ "-an", "-f", "null", "-"], capture_output=True, text=True,
123
+ timeout=FF_TIMEOUT_LONG)
124
+ cuts = [float(t) for t in re.findall(r"pts_time:([\d.]+)", r.stdout)
125
+ if 0.2 < float(t) < dur - 0.2]
126
+ bounds = [0.0] + cuts + [dur]
127
+ shots = [b - a for a, b in zip(bounds, bounds[1:]) if b - a > 0.05]
128
+ vol = subprocess.run([exe, "-nostats", "-i", path, "-af", "volumedetect",
129
+ "-f", "null", "-"], capture_output=True, text=True,
130
+ timeout=FF_TIMEOUT_LONG).stderr
131
+ m = re.search(r"mean_volume: (-?[\d.]+)", vol)
132
+ return {"dur": round(dur, 1), "cuts": len(cuts),
133
+ "asl": round(dur / max(1, len(shots)), 2),
134
+ "cpm": round(len(cuts) / (dur / 60), 1) if dur else 0,
135
+ "long_share": round(sum(s for s in shots if s > LONG_SHOT) / dur, 2) if dur else 0,
136
+ "mean_volume": float(m.group(1)) if m else None}
137
+
138
+
139
+ def tail_static_from_log(log: str, tail: float) -> float:
140
+ """freezedetect log → frozen seconds at the very end (pure function).
141
+
142
+ Only a freeze that lasts **until the end** counts; a mid-file freeze that recovers is
143
+ long_share's business. Times are relative to the scan start; ``tail`` is the scan
144
+ window length."""
145
+ starts = [float(t) for t in re.findall(r"freeze_start: ([\d.]+)", log)]
146
+ ends = [float(t) for t in re.findall(r"freeze_end: ([\d.]+)", log)]
147
+ if not starts:
148
+ return 0.0
149
+ # The last freeze has no freeze_end (frozen to EOF), or its end touches the end.
150
+ if len(ends) < len(starts) or (ends and tail - ends[-1] < 0.5):
151
+ return round(tail - starts[-1], 1)
152
+ return 0.0
153
+
154
+
155
+ def tail_static(exe: str, path: str, dur: float) -> dict:
156
+ """Seconds of frozen picture at the end (end card + any freeze). Scans the last ~15 s.
157
+ If the measurement fails the metric is omitted, never failed: a broken checker must not
158
+ block delivery."""
159
+ try:
160
+ seek = max(0.0, dur - 15.0)
161
+ r = subprocess.run([exe, "-nostats", "-ss", f"{seek:.2f}", "-i", path,
162
+ "-vf", "freezedetect=n=0.003:d=1.5", "-an", "-f", "null", "-"],
163
+ capture_output=True, text=True, timeout=FF_TIMEOUT_SHORT)
164
+ return {"tail_static": tail_static_from_log(r.stderr, dur - seek)}
165
+ except Exception as e: # noqa: BLE001
166
+ print(f"tail_static skipped: {str(e)[:120]}", file=sys.stderr)
167
+ return {}
168
+
169
+
170
+ def have_speech() -> bool:
171
+ try:
172
+ import faster_whisper # noqa: F401, PLC0415
173
+ return True
174
+ except ImportError:
175
+ return False
176
+
177
+
178
+ def speech_metrics(path: str, model_name: str | None = None) -> dict:
179
+ """Dialogue density via faster-whisper (optional extra). {} when not installed."""
180
+ try:
181
+ from faster_whisper import WhisperModel # noqa: PLC0415
182
+ except ImportError:
183
+ return {}
184
+ model = WhisperModel(model_name or os.environ.get("DRAMAPACK_WHISPER_MODEL", "base"),
185
+ device="cpu", compute_type="int8")
186
+ segs, info = model.transcribe(path, vad_filter=True, beam_size=1)
187
+ segs = sorted(segs, key=lambda s: s.start)
188
+ dur = info.duration or 1
189
+ zh = info.language == "zh"
190
+ words = sum(len(_CJK.findall(s.text)) if zh else len(s.text.split()) for s in segs)
191
+ gaps, prev = [], 0.0
192
+ for s in segs:
193
+ gaps.append(s.start - prev)
194
+ prev = max(prev, s.end)
195
+ gaps.append(dur - prev)
196
+ rate = words / (dur / 60)
197
+ # VAD segments overlap slightly / carry padding, so the sum can exceed duration —
198
+ # clamp to 1.0 rather than report a 1.1 that looks like a bug.
199
+ return {"lang": info.language,
200
+ "speech_share": round(min(1.0, sum(s.end - s.start for s in segs) / dur), 2),
201
+ # Chinese is counted per character; scale by 3/5 onto the same ruler as WPM.
202
+ "wpm": round(rate / (5 / 3) if zh else rate),
203
+ "wpm_raw": round(rate), "max_gap": round(max(gaps), 1),
204
+ "first_speech": round(segs[0].start, 1) if segs else None}
205
+
206
+
207
+ def load_hook_plugin(spec: str):
208
+ """``"package.module:function"`` → callable ``f(path, ffmpeg_exe) -> dict`` returning
209
+ ``{"fails": [str], "warns": [str], "note": str}``. Bring your own model and credentials
210
+ (read them from your environment inside the plugin)."""
211
+ mod, _, fn = spec.partition(":")
212
+ if not mod or not fn:
213
+ raise ValueError("hook plugin must be 'module:function'")
214
+ return getattr(importlib.import_module(mod), fn)
215
+
216
+
217
+ def hook_metrics(exe: str, path: str, plugin) -> dict:
218
+ """Hook window check (first ~3.5 s) through a user plugin. Failures of the plugin
219
+ itself (no network, no key, model error) degrade to "skipped", never to red."""
220
+ try:
221
+ r = plugin(path, exe) or {}
222
+ except Exception as e: # noqa: BLE001
223
+ print(f"hook check skipped: {str(e)[:120]}", file=sys.stderr)
224
+ return {}
225
+ fails, warns = list(r.get("fails") or []), list(r.get("warns") or [])
226
+ return {"hook": RED if fails else (YELLOW if warns else GREEN),
227
+ "hook_fails": fails, "hook_warns": warns,
228
+ "hook_note": str(r.get("note") or "")[:200]}
229
+
230
+
231
+ def audit(path: str, *, do_speech: bool = True, bounds: list | None = None,
232
+ hook_plugin=None, keys: set | None = None) -> dict:
233
+ """Measure one file. ``keys`` restricts which metrics are graded (None = all)."""
234
+ exe = ffmpeg_exe()
235
+ m = cut_metrics(exe, path)
236
+ m.update(tail_static(exe, path, m.get("dur") or 0.0))
237
+ if bounds:
238
+ m.update(seam_jump(exe, path, bounds))
239
+ if do_speech:
240
+ m.update(speech_metrics(path))
241
+ if hook_plugin:
242
+ m.update(hook_metrics(exe, path, hook_plugin))
243
+ m["file"] = os.path.basename(path)
244
+ grades = {k: grade(k, m.get(k)) for k in T}
245
+ if m.get("hook"):
246
+ grades["hook"] = m["hook"]
247
+ if keys is not None:
248
+ grades = {k: g for k, g in grades.items() if k in keys}
249
+ m["grades"] = grades
250
+ m["red"] = [k for k, g in grades.items() if g == RED]
251
+ return m
252
+
253
+
254
+ def audit_pair(master_path: str, cut_path: str | None = None, **kw) -> list:
255
+ """Audit a master, optionally with its raw cut: the cut is graded on baseline metrics,
256
+ the master on master-only metrics (see module docstring)."""
257
+ if not cut_path:
258
+ return [audit(master_path, **kw)]
259
+ hook = kw.pop("hook_plugin", None)
260
+ bounds = kw.pop("bounds", None)
261
+ cut = audit(cut_path, bounds=bounds, keys=(set(T) - MASTER_KEYS) | {"dur"}, **kw)
262
+ mas = audit(master_path, hook_plugin=hook, keys=MASTER_KEYS, **kw)
263
+ cut["file"], mas["file"] = f"cut:{cut['file']}", f"master:{mas['file']}"
264
+ return [cut, mas]
265
+
266
+
267
+ ROW = ["dur", "asl", "cpm", "long_share", "speech_share", "wpm", "max_gap", "mean_volume",
268
+ "seam_db", "first_speech", "tail_static", "hook"]
269
+ _SHORT = {GREEN: "G", YELLOW: "Y", RED: "R"}
270
+
271
+
272
+ def format_table(rows: list) -> str:
273
+ hdr = ["file"] + ROW
274
+ w = {c: max(len(c), *(len(str(r.get(c, "-"))) + 3 for r in rows)) for c in hdr}
275
+ out = [" ".join(c.ljust(w[c]) for c in hdr)]
276
+ for r in rows:
277
+ cells = []
278
+ for c in hdr:
279
+ v = r.get(c, "-")
280
+ g = r["grades"].get(c)
281
+ mark = f"[{_SHORT[g]}]" if g in _SHORT else ""
282
+ cells.append(f"{v}{mark}".ljust(w[c]))
283
+ out.append(" ".join(cells))
284
+ out.append("\nthresholds (green/red): " + ", ".join(f"{k} {T[k][0]}/{T[k][1]}" for k in T))
285
+ for r in rows:
286
+ if r["red"]:
287
+ out.append(f"\n{r['file']} red: {', '.join(r['red'])}")
288
+ for f in r.get("hook_fails") or []:
289
+ out.append(f" x {f}")
290
+ for wn in r.get("hook_warns") or []:
291
+ out.append(f" ~ {wn}")
292
+ if r.get("hook_note"):
293
+ out.append(f" hook window: {r['hook_note']}")
294
+ return "\n".join(out)
dramapack/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ """``dramapack`` command line: master | package | audit | check-rights | inspect | mcp.
2
+
3
+ Exit codes (stable):
4
+ 0 success; nothing red (yellow findings / non-blocking gaps are still 0)
5
+ 1 completed, but the result is red (audit red metric, rights red, or
6
+ ``package --strict`` with gaps or a red rights check)
7
+ 2 usage error (bad arguments)
8
+ 3 runtime failure (ffmpeg missing, unreadable input, encode failed)
9
+
10
+ With ``--json`` the only thing on stdout is one JSON object (progress goes to stderr).
11
+ Failures print ``{"ok": false, "error": "..."}``.
12
+ """
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from . import api
18
+ from . import master as M
19
+ from .provenance import PROVENANCE, footer_line
20
+
21
+ EXIT_OK, EXIT_RED, EXIT_USAGE, EXIT_FAIL = 0, 1, 2, 3
22
+
23
+
24
+ def _emit(a, res: dict, human) -> None:
25
+ if a.json:
26
+ print(json.dumps(res, ensure_ascii=False, indent=2))
27
+ else:
28
+ human(res)
29
+
30
+
31
+ def _cmd_master(a) -> int:
32
+ end_card = None
33
+ if a.end_card_title or a.end_card_line:
34
+ end_card = {"title": a.end_card_title or "", "lines": a.end_card_line or [],
35
+ "site": a.end_card_site or ""}
36
+ res = api.master_episode(a.input, a.output, srt=a.srt, burn=not a.no_burn, bed=a.bed,
37
+ music=a.music, music_db=a.music_db, head_trim=a.head_trim,
38
+ end_card=end_card, card_secs=a.card_secs, cover_band=a.cover_band,
39
+ subtitle_font=a.subtitle_font, normalize=not a.no_normalize)
40
+ _emit(a, res, lambda r: print(f"wrote {r['output']} ({r['spec'].get('duration_s')} s)"))
41
+ return EXIT_OK
42
+
43
+
44
+ def _cmd_package(a) -> int:
45
+ eps = [int(x) for x in a.episodes.split(",")] if a.episodes else None
46
+ res = api.build_package(a.input_dir, a.output_dir, show=a.show, rights=a.rights,
47
+ title=a.title, studio=a.studio, language=a.language, bed=a.bed,
48
+ music=a.music, masters_ready=a.masters_ready, episodes=eps,
49
+ platform_pairs=a.platform_pairs, clean=not a.no_clean,
50
+ covers=not a.no_covers, timeline=not a.no_timeline,
51
+ keep_work=a.keep_work)
52
+
53
+ def human(r):
54
+ print(f"\n{len(r['episodes'])} episodes -> {r['output_dir']} [{r['status']}]")
55
+ print(f"rights check: {r['rights_status']}")
56
+ for g in r["gaps"]:
57
+ print(f" gap: {g}")
58
+ for n in r["notes"]:
59
+ print(f" note: {n}")
60
+ print(footer_line())
61
+
62
+ _emit(a, res, human)
63
+ if res["status"] == "red" or (a.strict and res["gaps"]):
64
+ return EXIT_RED
65
+ return EXIT_OK
66
+
67
+
68
+ def _cmd_audit(a) -> int:
69
+ from .audit import format_table
70
+ bounds = [float(x) for x in a.bounds.split(",")] if a.bounds else None
71
+ res = api.audit_files(a.files, cut=a.cut, bounds=bounds, speech=not a.no_speech,
72
+ hook_plugin=a.hook_plugin)
73
+
74
+ def human(r):
75
+ for s in r["skipped"]:
76
+ print(f"skipped: {s}", file=sys.stderr)
77
+ print(format_table(r["rows"]))
78
+
79
+ _emit(a, res, human)
80
+ return EXIT_RED if res["status"] == "red" else EXIT_OK
81
+
82
+
83
+ def _cmd_rights(a) -> int:
84
+ from .rights import format_report
85
+ res = api.check_rights(a.rights, input_dir=a.input, bed=a.bed, music=a.music,
86
+ subtitle_font=None if a.no_mastering_fonts else a.subtitle_font,
87
+ end_card=a.end_card and not a.no_mastering_fonts,
88
+ allow_fonts=a.allow_font or ())
89
+ _emit(a, res, lambda r: print(format_report(r)))
90
+ return EXIT_RED if res["status"] == "red" else EXIT_OK
91
+
92
+
93
+ def _cmd_inspect(a) -> int:
94
+ res = api.inspect_file(a.file)
95
+
96
+ def human(r):
97
+ for k, v in r.items():
98
+ if k not in ("ok",):
99
+ print(f"{k:28} {v}")
100
+
101
+ _emit(a, res, human)
102
+ return EXIT_OK
103
+
104
+
105
+ def _cmd_mcp(a) -> int:
106
+ from .mcp_server import run
107
+ run()
108
+ return EXIT_OK
109
+
110
+
111
+ def build_parser() -> argparse.ArgumentParser:
112
+ ap = argparse.ArgumentParser(
113
+ prog="dramapack",
114
+ description=f"dramapack {PROVENANCE['version']} by Dawnwood — short-drama delivery "
115
+ f"packs, mastering, acceptance audit and rights check. {PROVENANCE['url']}",
116
+ epilog="exit codes: 0 ok, 1 red result, 2 usage error, 3 runtime failure")
117
+ ap.add_argument("--version", action="version", version=f"%(prog)s {PROVENANCE['version']}")
118
+ sub = ap.add_subparsers(dest="cmd", required=True)
119
+
120
+ def add(name, **kw):
121
+ p = sub.add_parser(name, **kw)
122
+ p.add_argument("--json", action="store_true",
123
+ help="print one JSON object on stdout (progress goes to stderr)")
124
+ return p
125
+
126
+ m = add("master", help="master one episode (subtitles, -14 LUFS, bed, end card)")
127
+ m.add_argument("input")
128
+ m.add_argument("output")
129
+ m.add_argument("--srt", help="subtitles to burn in")
130
+ m.add_argument("--no-burn", action="store_true", help="clean master: do not burn subtitles")
131
+ m.add_argument("--bed", choices=M.BED_MOODS, help="lay a procedural music bed of this mood")
132
+ m.add_argument("--music", help="your own music bed file (declare it in rights.json)")
133
+ m.add_argument("--music-db", type=float, default=M.MUSIC_DB)
134
+ m.add_argument("--head-trim", type=float, default=0.0, help="cut N seconds off the head")
135
+ m.add_argument("--end-card-title")
136
+ m.add_argument("--end-card-line", action="append", help="CTA line (repeatable)")
137
+ m.add_argument("--end-card-site")
138
+ m.add_argument("--card-secs", type=float, default=M.CARD_SECS)
139
+ m.add_argument("--cover-band", type=float, default=0.0,
140
+ help="blur this fraction of the frame bottom first (re-subtitling)")
141
+ m.add_argument("--subtitle-font", default=M.SUB_FONT)
142
+ m.add_argument("--no-normalize", action="store_true")
143
+ m.set_defaults(func=_cmd_master)
144
+
145
+ p = add("package", help="build a platform delivery pack from an input folder")
146
+ p.add_argument("input_dir")
147
+ p.add_argument("output_dir")
148
+ p.add_argument("--show", help="show.json (default: <input_dir>/show.json)")
149
+ p.add_argument("--rights", help="rights.json (default: <input_dir>/rights.json)")
150
+ p.add_argument("--title")
151
+ p.add_argument("--studio")
152
+ p.add_argument("--language")
153
+ p.add_argument("--bed", choices=M.BED_MOODS, help="procedural music bed mood")
154
+ p.add_argument("--music", help="your own music bed file")
155
+ p.add_argument("--masters-ready", action="store_true",
156
+ help="NN.mp4 are finished subtitled masters (clean master from NN.cut.mp4)")
157
+ p.add_argument("--episodes", help="comma-separated episode numbers (default: all)")
158
+ p.add_argument("--platform-pairs", action="store_true",
159
+ help="also build two-episodes-per-file platform cuts")
160
+ p.add_argument("--no-clean", action="store_true")
161
+ p.add_argument("--no-covers", action="store_true")
162
+ p.add_argument("--no-timeline", action="store_true")
163
+ p.add_argument("--keep-work", action="store_true", help="keep the _work scratch folder")
164
+ p.add_argument("--strict", action="store_true",
165
+ help="exit 1 when the pack has any gap (red results always exit 1)")
166
+ p.set_defaults(func=_cmd_package)
167
+
168
+ u = add("audit", help="acceptance audit against short-drama benchmarks")
169
+ u.add_argument("files", nargs="+")
170
+ u.add_argument("--cut", help="raw cut of the (single) master: grade each on its own metrics")
171
+ u.add_argument("--bounds", help="splice points in seconds, comma-separated (seam metric)")
172
+ u.add_argument("--no-speech", action="store_true", help="skip faster-whisper metrics")
173
+ u.add_argument("--hook-plugin", metavar="MODULE:FUNC",
174
+ help="optional hook-window checker f(path, ffmpeg) -> {fails, warns, note}")
175
+ u.set_defaults(func=_cmd_audit)
176
+
177
+ r = add("check-rights", help="grade declared music and fonts red/yellow/green")
178
+ r.add_argument("rights", nargs="?", help="rights.json")
179
+ r.add_argument("--input", help="input folder (rights.json default, *.ass font scan)")
180
+ r.add_argument("--bed", choices=M.BED_MOODS, help="procedural bed in use (auto-declared)")
181
+ r.add_argument("--music", help="music file used in mastering (must be declared)")
182
+ r.add_argument("--subtitle-font", default=M.SUB_FONT)
183
+ r.add_argument("--end-card", action="store_true", help="include the end-card font")
184
+ r.add_argument("--no-mastering-fonts", action="store_true",
185
+ help="don't check the fonts this tool burns in")
186
+ r.add_argument("--allow-font", action="append", help="extend the font allowlist")
187
+ r.set_defaults(func=_cmd_rights)
188
+
189
+ i = add("inspect", help="measured specs + integrated LUFS / true peak of a media file")
190
+ i.add_argument("file")
191
+ i.set_defaults(func=_cmd_inspect)
192
+
193
+ s = sub.add_parser("mcp", help="run the MCP server on stdio (for AI agents)")
194
+ s.set_defaults(func=_cmd_mcp, json=False)
195
+ return ap
196
+
197
+
198
+ def main(argv=None) -> int:
199
+ a = build_parser().parse_args(argv)
200
+ try:
201
+ return a.func(a)
202
+ except Exception as e: # noqa: BLE001
203
+ msg = f"{type(e).__name__}: {e}"
204
+ if getattr(a, "json", False):
205
+ print(json.dumps({"ok": False, "error": msg}))
206
+ else:
207
+ print(f"dramapack: error: {msg}", file=sys.stderr)
208
+ return EXIT_FAIL
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())