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 +8 -0
- dramapack/__main__.py +5 -0
- dramapack/api.py +152 -0
- dramapack/audit.py +294 -0
- dramapack/cli.py +212 -0
- dramapack/ff.py +75 -0
- dramapack/master.py +658 -0
- dramapack/mcp_server.py +141 -0
- dramapack/package.py +722 -0
- dramapack/provenance.py +21 -0
- dramapack/rights.py +329 -0
- dramapack/srt.py +140 -0
- dramapack-0.1.0.dist-info/METADATA +137 -0
- dramapack-0.1.0.dist-info/RECORD +18 -0
- dramapack-0.1.0.dist-info/WHEEL +4 -0
- dramapack-0.1.0.dist-info/entry_points.txt +2 -0
- dramapack-0.1.0.dist-info/licenses/LICENSE +202 -0
- dramapack-0.1.0.dist-info/licenses/NOTICE +9 -0
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
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())
|