renoise-kit 0.5.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.
- renoise_kit-0.5.0.dist-info/METADATA +307 -0
- renoise_kit-0.5.0.dist-info/RECORD +33 -0
- renoise_kit-0.5.0.dist-info/WHEEL +5 -0
- renoise_kit-0.5.0.dist-info/entry_points.txt +2 -0
- renoise_kit-0.5.0.dist-info/licenses/LICENSE +21 -0
- renoise_kit-0.5.0.dist-info/top_level.txt +1 -0
- rn/__init__.py +2 -0
- rn/__main__.py +5 -0
- rn/align.py +92 -0
- rn/assemble.py +602 -0
- rn/assets.py +125 -0
- rn/audio.py +79 -0
- rn/captions.py +343 -0
- rn/capture.py +97 -0
- rn/cli.py +557 -0
- rn/compare.py +115 -0
- rn/cuts.py +81 -0
- rn/editops.py +252 -0
- rn/edl.py +898 -0
- rn/export.py +200 -0
- rn/faces.py +135 -0
- rn/ff.py +183 -0
- rn/fonts.py +87 -0
- rn/frames.py +205 -0
- rn/fx/__init__.py +223 -0
- rn/fx/fx.js +150 -0
- rn/listen.py +199 -0
- rn/matte.py +58 -0
- rn/qa.py +212 -0
- rn/ref.py +74 -0
- rn/ref_guide.md +245 -0
- rn/signal.py +190 -0
- rn/text.py +112 -0
rn/listen.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""rn listen: audio as facts and one picture (waveform, spectrogram, loudness, onsets, beats, silences, words)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .audio import measure
|
|
11
|
+
from .ff import RnError, probe, run
|
|
12
|
+
from .frames import DEFAULT_MAX_KB, _save_under
|
|
13
|
+
|
|
14
|
+
ANALYSIS_RATE = 22050
|
|
15
|
+
CURVE_STEP = 0.5
|
|
16
|
+
SILENCE_DB = -50.0 # frame RMS below this, for at least SILENCE_MIN seconds
|
|
17
|
+
SILENCE_MIN = 0.3
|
|
18
|
+
MAX_EVENTS = 400 # onsets / beats kept in the result; the strongest onsets win when thinned
|
|
19
|
+
BEAT_CLEAR = 0.15 # autocorrelation of the onset envelope at the beat period, 0..1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _short_term_curve(wav: Path) -> list[dict]:
|
|
23
|
+
"""Short-term (3 s) loudness every CURVE_STEP seconds from ebur128's frame log (it logs every 0.1 s)."""
|
|
24
|
+
result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', str(wav), '-af', 'ebur128',
|
|
25
|
+
'-f', 'null', '-'], capture_output=True, text=True)
|
|
26
|
+
curve, next_t = [], 0.0
|
|
27
|
+
for match in re.finditer(r't:\s*([\d.]+)\s.*?M:\s*(-?[\d.]+|-inf)\s+S:\s*(-?[\d.]+|-inf)', result.stderr):
|
|
28
|
+
t = float(match.group(1))
|
|
29
|
+
level = match.group(3) if t >= 3 else match.group(2)
|
|
30
|
+
if t + 1e-6 >= next_t and level != '-inf':
|
|
31
|
+
curve.append({'t': round(t, 2), 'lufs': max(float(level), -70.0)})
|
|
32
|
+
next_t = t + CURVE_STEP
|
|
33
|
+
return curve
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _silences(samples, rate: int) -> list[dict]:
|
|
37
|
+
import librosa
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
hop = 512
|
|
41
|
+
rms = librosa.feature.rms(y=samples, frame_length=2048, hop_length=hop)[0]
|
|
42
|
+
quiet = 20 * np.log10(np.maximum(rms, 1e-9)) < SILENCE_DB
|
|
43
|
+
ranges, start = [], None
|
|
44
|
+
for index, flag in enumerate(list(quiet) + [False]):
|
|
45
|
+
if flag and start is None:
|
|
46
|
+
start = index
|
|
47
|
+
elif not flag and start is not None:
|
|
48
|
+
begin, end = start * hop / rate, min(index * hop / rate, len(samples) / rate)
|
|
49
|
+
if end - begin >= SILENCE_MIN:
|
|
50
|
+
ranges.append({'start': round(begin, 3), 'end': round(end, 3)})
|
|
51
|
+
start = None
|
|
52
|
+
return ranges
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _beats(samples, rate: int, onset_env, hop: int = 512) -> dict:
|
|
56
|
+
import librosa
|
|
57
|
+
import numpy as np
|
|
58
|
+
|
|
59
|
+
tempo, frames = librosa.beat.beat_track(onset_envelope=onset_env, sr=rate, hop_length=hop)
|
|
60
|
+
bpm = float(np.atleast_1d(tempo)[0])
|
|
61
|
+
if bpm <= 0 or len(frames) < 3:
|
|
62
|
+
return {'bpm': None, 'alternatives': [], 'times': [], 'clear': False}
|
|
63
|
+
# The tracker's tempo sits on a coarse frame grid (~2 bpm steps); the mean beat interval over the whole span is finer.
|
|
64
|
+
beat_times = librosa.frames_to_time(frames, sr=rate, hop_length=hop)
|
|
65
|
+
bpm = float(60 * (len(beat_times) - 1) / (beat_times[-1] - beat_times[0]))
|
|
66
|
+
autocorr = librosa.autocorrelate(onset_env - onset_env.mean())
|
|
67
|
+
autocorr = autocorr / autocorr[0] if autocorr[0] > 0 else autocorr
|
|
68
|
+
|
|
69
|
+
def strength(candidate: float) -> float:
|
|
70
|
+
lag = int(round(60 * rate / (hop * candidate)))
|
|
71
|
+
return float(max(autocorr[max(lag - 1, 0):lag + 2], default=0)) if lag < len(autocorr) else 0.0
|
|
72
|
+
|
|
73
|
+
# The BPM octave check: a beat tracker can report half or double the felt pulse, so give all three with
|
|
74
|
+
# the periodicity strength measured at each; the main bpm is the tracker's.
|
|
75
|
+
alternatives = [{'bpm': round(value, 1), 'strength': round(strength(value), 3)} for value in (bpm / 2, bpm * 2)]
|
|
76
|
+
times = librosa.frames_to_time(frames, sr=rate, hop_length=hop)
|
|
77
|
+
return {'bpm': round(bpm, 1), 'strength': round(strength(bpm), 3), 'alternatives': alternatives,
|
|
78
|
+
'times': [round(float(t), 3) for t in times[:MAX_EVENTS]], 'clear': strength(bpm) >= BEAT_CLEAR}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _transcribe(wav: Path, *, language: str | None, model: str) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
from faster_whisper import WhisperModel
|
|
84
|
+
except ImportError:
|
|
85
|
+
raise RnError("--transcribe needs faster-whisper (extra \"transcribe\"): uv tool install 'renoise-kit[all]'")
|
|
86
|
+
import numpy as np
|
|
87
|
+
# Decode here and pass samples: faster-whisper's own file decoding goes through PyAV, whose API it does not pin
|
|
88
|
+
# (av 16 dropped the `metadata_errors` argument it passes).
|
|
89
|
+
pcm = subprocess.run(['ffmpeg', '-hide_banner', '-v', 'error', '-nostdin', '-i', str(wav), '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'],
|
|
90
|
+
capture_output=True, check=True).stdout
|
|
91
|
+
audio = np.frombuffer(pcm, dtype=np.float32)
|
|
92
|
+
segments, info = WhisperModel(model, device='cpu', compute_type='int8').transcribe(audio, language=language, word_timestamps=True)
|
|
93
|
+
words, texts = [], []
|
|
94
|
+
for segment in segments:
|
|
95
|
+
texts.append({'start': round(segment.start, 3), 'end': round(segment.end, 3), 'text': segment.text.strip()})
|
|
96
|
+
# Whisper's leading space marks a new word in space-delimited languages; rn captions relies on it.
|
|
97
|
+
words.extend({'word': w.word, 'start': round(w.start, 3), 'end': round(w.end, 3), 'p': round(w.probability, 3)}
|
|
98
|
+
for w in segment.words or [])
|
|
99
|
+
return {'words': words, 'segments': texts, 'language': info.language}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _draw(result: dict, samples, rate: int, onset_times: list[float]):
|
|
103
|
+
import librosa
|
|
104
|
+
import matplotlib
|
|
105
|
+
matplotlib.use('Agg')
|
|
106
|
+
import matplotlib.pyplot as plt
|
|
107
|
+
import numpy as np
|
|
108
|
+
from PIL import Image
|
|
109
|
+
|
|
110
|
+
duration = result['duration']
|
|
111
|
+
fig, axes = plt.subplots(4, 1, figsize=(10, 6), sharex=True, gridspec_kw={'height_ratios': [2, 3, 1.5, 0.8]})
|
|
112
|
+
wave, spec, loud, ticks = axes
|
|
113
|
+
times = np.arange(len(samples)) / rate
|
|
114
|
+
step = max(1, len(samples) // 4000) # min/max envelope keeps spikes visible
|
|
115
|
+
blocks = len(samples) // step
|
|
116
|
+
shaped = samples[:blocks * step].reshape(blocks, step)
|
|
117
|
+
block_t = times[:blocks * step:step]
|
|
118
|
+
wave.fill_between(block_t, shaped.min(axis=1), shaped.max(axis=1), color='#3b6ea5', linewidth=0)
|
|
119
|
+
wave.set_ylabel('amp')
|
|
120
|
+
mel = librosa.power_to_db(librosa.feature.melspectrogram(y=samples, sr=rate, n_mels=96), ref=np.max)
|
|
121
|
+
spec.imshow(mel, aspect='auto', origin='lower', cmap='magma', extent=[0, len(samples) / rate, 0, 96], vmin=-80, vmax=0)
|
|
122
|
+
spec.set_ylabel('mel')
|
|
123
|
+
spec.set_yticks([])
|
|
124
|
+
curve = result['loudness']['curve']
|
|
125
|
+
if curve:
|
|
126
|
+
loud.plot([p['t'] for p in curve], [p['lufs'] for p in curve], color='#d95f02')
|
|
127
|
+
loud.set_ylabel('LUFS')
|
|
128
|
+
ticks.vlines(onset_times, 0.55, 1, color='#1b9e77', linewidth=0.8)
|
|
129
|
+
ticks.vlines(result['beats']['times'], 0, 0.45, color='#e7298a', linewidth=1.2)
|
|
130
|
+
ticks.set_yticks([0.225, 0.775])
|
|
131
|
+
ticks.set_yticklabels(['beat', 'onset'], fontsize=6)
|
|
132
|
+
ticks.set_xlabel('seconds')
|
|
133
|
+
for silence in result['silences']:
|
|
134
|
+
for axis in (wave, loud, ticks):
|
|
135
|
+
axis.axvspan(silence['start'], silence['end'], color='grey', alpha=0.35, linewidth=0)
|
|
136
|
+
ticks.set_xlim(0, duration)
|
|
137
|
+
lufs, bpm = result['loudness']['integrated'], result['beats']['bpm']
|
|
138
|
+
fig.suptitle(f"{Path(result['path']).name} {duration:.2f}s {lufs} LUFS "
|
|
139
|
+
f"{str(bpm) + ' bpm' if bpm and result['beats']['clear'] else 'no clear pulse'}", fontsize=10)
|
|
140
|
+
fig.tight_layout()
|
|
141
|
+
buffer = io.BytesIO()
|
|
142
|
+
fig.savefig(buffer, format='png', dpi=100)
|
|
143
|
+
plt.close(fig)
|
|
144
|
+
buffer.seek(0)
|
|
145
|
+
return Image.open(buffer).convert('RGB')
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def listen_command(path: str, *, out: str | None, transcribe: bool, language: str | None, model: str, max_kb: int = DEFAULT_MAX_KB) -> dict:
|
|
149
|
+
import librosa
|
|
150
|
+
import numpy as np
|
|
151
|
+
|
|
152
|
+
info = probe(path)
|
|
153
|
+
if not info['audio']:
|
|
154
|
+
raise RnError(f'{path}: no audio stream')
|
|
155
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
156
|
+
wav = Path(tmp) / 'audio.wav'
|
|
157
|
+
run(['ffmpeg', '-i', path, '-vn', '-ac', '1', '-ar', str(ANALYSIS_RATE), '-y', str(wav)])
|
|
158
|
+
samples, rate = librosa.load(str(wav), sr=None, mono=True)
|
|
159
|
+
loudness = measure(wav)
|
|
160
|
+
hop = 512
|
|
161
|
+
onset_env = librosa.onset.onset_strength(y=samples, sr=rate, hop_length=hop)
|
|
162
|
+
onset_frames = librosa.onset.onset_detect(onset_envelope=onset_env, sr=rate, hop_length=hop)
|
|
163
|
+
if len(onset_frames) > MAX_EVENTS: # keep the strongest, back in time order
|
|
164
|
+
onset_frames = np.sort(onset_frames[np.argsort(onset_env[onset_frames])[-MAX_EVENTS:]])
|
|
165
|
+
onset_times = [round(float(t), 3) for t in librosa.frames_to_time(onset_frames, sr=rate, hop_length=hop)]
|
|
166
|
+
result: dict = {
|
|
167
|
+
'path': path,
|
|
168
|
+
'duration': round(len(samples) / rate, 3),
|
|
169
|
+
'sample_rate': info['audio']['sample_rate'],
|
|
170
|
+
'loudness': {'integrated': loudness['lufs'], 'true_peak': loudness['true_peak'], 'curve': _short_term_curve(wav)},
|
|
171
|
+
'silences': _silences(samples, rate),
|
|
172
|
+
'onsets': onset_times,
|
|
173
|
+
'beats': _beats(samples, rate, onset_env),
|
|
174
|
+
}
|
|
175
|
+
if transcribe:
|
|
176
|
+
result.update(_transcribe(wav, language=language, model=model))
|
|
177
|
+
target = Path(out) if out else Path('listen') / f'{Path(path).stem}.jpg'
|
|
178
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
size = _save_under(_draw(result, samples, rate, onset_times), target, max_kb)
|
|
180
|
+
result['image'] = {'path': str(target), 'bytes': size}
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def summary_lines(result: dict) -> list[str]:
|
|
185
|
+
loudness, beats = result['loudness'], result['beats']
|
|
186
|
+
lines = [f"{result['path']}: {result['duration']:.2f}s {result['sample_rate']} Hz",
|
|
187
|
+
f"loudness {loudness['integrated']} LUFS true peak {loudness['true_peak']} dBTP"]
|
|
188
|
+
if beats['bpm'] and beats['clear']:
|
|
189
|
+
alternatives = ', '.join(f"{a['bpm']} ({a['strength']})" for a in beats['alternatives'])
|
|
190
|
+
lines.append(f"bpm {beats['bpm']} (strength {beats['strength']}; alternatives {alternatives}) {len(beats['times'])} beats")
|
|
191
|
+
else:
|
|
192
|
+
lines.append('no clear pulse')
|
|
193
|
+
lines.append(f"{len(result['onsets'])} onsets {len(result['silences'])} silences"
|
|
194
|
+
+ ''.join(f" {s['start']}-{s['end']}s" for s in result['silences'][:3]))
|
|
195
|
+
if 'words' in result:
|
|
196
|
+
first = ' '.join(w['word'] for w in result['words'][:8])
|
|
197
|
+
lines.append(f"{len(result['words'])} words ({result['language']}): {first}")
|
|
198
|
+
lines.append(f"image {result['image']['path']} ({result['image']['bytes']} bytes)")
|
|
199
|
+
return lines
|
rn/matte.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""rn matte: the subject's alpha in every frame (rembg), as a numbered grey PNG sequence (white = subject) that an EDL
|
|
2
|
+
uses as an overlay "mask" (text behind a person, a graphic that stays off them) or, with --cutout, as the cut-out
|
|
3
|
+
subject itself. Measurement-grade, not roto: check the edges on a sheet.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .ff import RnError, probe, run, source_fps
|
|
12
|
+
|
|
13
|
+
MODELS = ('isnet-general-use', 'u2net_human_seg', 'u2net', 'u2netp', 'silueta', 'birefnet-general', 'birefnet-portrait')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def matte_command(path: str, *, out: str, model: str, every: int, cutout: bool) -> dict:
|
|
17
|
+
from PIL import Image
|
|
18
|
+
from rembg import new_session, remove
|
|
19
|
+
|
|
20
|
+
if every < 1:
|
|
21
|
+
raise RnError('--every must be >= 1')
|
|
22
|
+
info = probe(path)
|
|
23
|
+
video = info['video']
|
|
24
|
+
if not video or video['still']:
|
|
25
|
+
raise RnError(f'{path}: not a video (rembg itself handles a single image)')
|
|
26
|
+
fps = source_fps(info)
|
|
27
|
+
out_dir = Path(out)
|
|
28
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
for stale in out_dir.glob('*.png'):
|
|
30
|
+
stale.unlink()
|
|
31
|
+
session = new_session(model)
|
|
32
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
33
|
+
# Every decoded frame in order (no timestamps involved), so output n is input frame n - 1.
|
|
34
|
+
run(['ffmpeg', '-i', path, '-map', '0:v:0', '-fps_mode', 'passthrough', '-y', str(Path(tmp) / '%06d.png')])
|
|
35
|
+
frames = sorted(Path(tmp).glob('*.png'))
|
|
36
|
+
mask = None
|
|
37
|
+
for k, png in enumerate(frames):
|
|
38
|
+
image = Image.open(png).convert('RGB')
|
|
39
|
+
if k % every == 0 or mask is None:
|
|
40
|
+
mask = remove(image, session=session, only_mask=True).convert('L') # held in between with --every
|
|
41
|
+
target = out_dir / f'{k + 1:05d}.png'
|
|
42
|
+
if cutout:
|
|
43
|
+
rgba = image.convert('RGBA')
|
|
44
|
+
rgba.putalpha(mask)
|
|
45
|
+
rgba.save(target)
|
|
46
|
+
else:
|
|
47
|
+
mask.save(target)
|
|
48
|
+
fps_text = str(fps.numerator) if fps.denominator == 1 else f'{fps.numerator}/{fps.denominator}'
|
|
49
|
+
pattern = f'{out_dir.name}/%05d.png'
|
|
50
|
+
use = {'frames': pattern, 'fps': fps_text}
|
|
51
|
+
return {'dir': str(out_dir), 'frames': len(frames), 'model': model, 'every': every, 'cutout': cutout,
|
|
52
|
+
'edl': use if cutout else {'mask': {**use, 'mode': 'luma'}}}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def summary_lines(result: dict) -> list[str]:
|
|
56
|
+
kind = 'RGBA cut-outs' if result['cutout'] else 'grey mattes (white = subject)'
|
|
57
|
+
return [f'{result["dir"]}/%05d.png {result["frames"]} {kind} model {result["model"]}, computed every {result["every"]} frame(s)',
|
|
58
|
+
f'EDL: {json.dumps(result["edl"])}' + ('' if result['cutout'] else ' (on an overlay; "invert": true keeps it off the subject)')]
|
rn/qa.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""rn qa: delivery checks on the ENCODED file. FAIL blocks delivery; WARN needs a look (it may be intended)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .audio import measure
|
|
10
|
+
from .edl import Edit, load
|
|
11
|
+
from .ff import RnError, probe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scan(path: str) -> dict:
|
|
15
|
+
"""One decode pass: black intervals, frozen intervals and the union crop box (black bars)."""
|
|
16
|
+
result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', path, '-map', '0:v:0', '-vf',
|
|
17
|
+
'blackdetect=d=0.1:pix_th=0.10,freezedetect=n=-60dB:d=0.5,cropdetect=limit=24:round=2:reset=0',
|
|
18
|
+
'-f', 'null', '-'], capture_output=True, text=True)
|
|
19
|
+
if result.returncode != 0:
|
|
20
|
+
raise RnError(f'{path}: decode failed: {result.stderr.strip().splitlines()[-1:]}')
|
|
21
|
+
log = result.stderr
|
|
22
|
+
black = [(float(a), float(b)) for a, b in re.findall(r'black_start:([\d.]+) black_end:([\d.]+)', log)]
|
|
23
|
+
starts = [float(v) for v in re.findall(r'freeze_start: ([\d.]+)', log)]
|
|
24
|
+
ends = [float(v) for v in re.findall(r'freeze_end: ([\d.]+)', log)]
|
|
25
|
+
freezes = [(s, ends[i] if i < len(ends) else None) for i, s in enumerate(starts)]
|
|
26
|
+
crops = re.findall(r'crop=(\d+):(\d+):(\d+):(\d+)', log)
|
|
27
|
+
crop = tuple(int(v) for v in crops[-1]) if crops else None
|
|
28
|
+
return {'black': black, 'freeze': freezes, 'crop': crop}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SILENCE_DB, SILENCE_MIN = -70, 0.06 # digital silence inside a mix reads as a glitch, not a pause
|
|
32
|
+
JUMP_DB = 15 # between adjacent 100 ms RMS windows: right for a designed hit, wrong for a music edit
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sound(path: str, seconds: float) -> dict:
|
|
36
|
+
"""One decode pass: digital silences away from the head and tail, and level jumps between 100 ms windows."""
|
|
37
|
+
result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', path, '-map', '0:a:0', '-af',
|
|
38
|
+
f'aresample=48000,silencedetect=noise={SILENCE_DB}dB:d={SILENCE_MIN},asetnsamples=n=4800:p=0,'
|
|
39
|
+
'astats=metadata=1:reset=1,ametadata=mode=print:key=lavfi.astats.Overall.RMS_level:file=-',
|
|
40
|
+
'-f', 'null', '-'], capture_output=True, text=True)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
raise RnError(f'{path}: audio decode failed: {result.stderr.strip().splitlines()[-1:]}')
|
|
43
|
+
starts = [float(v) for v in re.findall(r'silence_start: (-?[\d.]+)', result.stderr)]
|
|
44
|
+
ends = [float(v) for v in re.findall(r'silence_end: ([\d.]+)', result.stderr)]
|
|
45
|
+
silences = [(max(0.0, s), ends[i] if i < len(ends) else seconds) for i, s in enumerate(starts)]
|
|
46
|
+
silences = [(a, b) for a, b in silences if a > 0.05 and b < seconds - 0.05] # lead-in and tail-out are a choice
|
|
47
|
+
levels = [float(v) for v in re.findall(r'RMS_level=(-?inf|-?[\d.]+)', result.stdout)]
|
|
48
|
+
# The last window is partial and holds the encoder's tail, so it reads as a drop; skip it like the head.
|
|
49
|
+
jumps = [(round(k * 0.1, 1), levels[k - 1], levels[k]) for k in range(1, len(levels))
|
|
50
|
+
if k * 0.1 < seconds - 0.15 and abs(levels[k] - levels[k - 1]) >= JUMP_DB and max(levels[k], levels[k - 1]) > -60]
|
|
51
|
+
return {'silence': silences, 'jumps': jumps}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fixed_frame_rate(path: str) -> bool | None:
|
|
55
|
+
"""The H.264 SPS fixed_frame_rate_flag, or None when the stream carries no timing info."""
|
|
56
|
+
result = subprocess.run(['ffmpeg', '-hide_banner', '-nostdin', '-i', path, '-map', '0:v:0', '-c', 'copy', '-bsf:v', 'trace_headers',
|
|
57
|
+
'-frames:v', '1', '-f', 'null', '-'], capture_output=True, text=True)
|
|
58
|
+
flag = re.search(r'fixed_frame_rate_flag\s+[01]\s+=\s+([01])', result.stderr)
|
|
59
|
+
return flag.group(1) == '1' if flag else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _intended(edit: Edit | None, kind: str) -> list[tuple[float, float]]:
|
|
63
|
+
"""Timeline ranges the EDL itself makes black or still, so qa does not flag them."""
|
|
64
|
+
if not edit:
|
|
65
|
+
return []
|
|
66
|
+
ranges = []
|
|
67
|
+
for clip in edit.base:
|
|
68
|
+
if (kind == 'black' and clip.kind == 'color') or (kind == 'freeze' and clip.kind in ('image', 'color')):
|
|
69
|
+
ranges.append((float(clip.timeline_start / edit.fps), float((clip.timeline_start + clip.frames) / edit.fps)))
|
|
70
|
+
return ranges
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _covered(interval: tuple[float, float | None], ranges: list[tuple[float, float]], end: float) -> bool:
|
|
74
|
+
start, stop = interval[0], interval[1] if interval[1] is not None else end
|
|
75
|
+
return any(a - 0.05 <= start and stop <= b + 0.05 for a, b in ranges)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_accept(values: list[str]) -> list[dict]:
|
|
79
|
+
"""--accept CHECK or CHECK@SECONDS."""
|
|
80
|
+
accepted = []
|
|
81
|
+
for value in values:
|
|
82
|
+
name, _, at = value.partition('@')
|
|
83
|
+
try:
|
|
84
|
+
accepted.append({'check': name, **({'at': float(at)} if at else {}), 'note': 'command line'})
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise RnError(f'--accept {value}: CHECK or CHECK@SECONDS, e.g. dead_air@12.5') from None
|
|
87
|
+
return accepted
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def qa_command(path: str, *, edl: str | None, frames: int | None, size: str | None, fps: str | None, lufs: float | None,
|
|
91
|
+
lufs_tolerance: float, true_peak: float | None, require_audio: bool, accept: list[dict] | None = None) -> dict:
|
|
92
|
+
edit = load(edl) if edl else None
|
|
93
|
+
checks: list[dict] = []
|
|
94
|
+
accepts = (edit.accept if edit else []) + (accept or [])
|
|
95
|
+
|
|
96
|
+
def check(name: str, status: str, detail: str):
|
|
97
|
+
checks.append({'check': name, 'status': status, 'detail': detail})
|
|
98
|
+
|
|
99
|
+
def keep(name: str, found: list, span) -> tuple[list, int]:
|
|
100
|
+
"""Findings of an interval check minus those accepted (an accept "at" inside the interval, or without "at")."""
|
|
101
|
+
kept = [f for f in found if not any(a['check'] == name and ('at' not in a or span(f)[0] - 0.05 <= a['at'] <= span(f)[1] + 0.05)
|
|
102
|
+
for a in accepts)]
|
|
103
|
+
return kept, len(found) - len(kept)
|
|
104
|
+
|
|
105
|
+
def note(text: str, dropped: int) -> str:
|
|
106
|
+
return text + (f' ({dropped} accepted)' if dropped else '')
|
|
107
|
+
|
|
108
|
+
info = probe(path, exact=True)
|
|
109
|
+
video, audio = info['video'], info['audio']
|
|
110
|
+
if not video or video['still']:
|
|
111
|
+
raise RnError(f'{path}: no video stream')
|
|
112
|
+
expected_frames = frames or (edit.total_frames if edit else None)
|
|
113
|
+
expected_size = size or (f'{edit.width}x{edit.height}' if edit else None)
|
|
114
|
+
expected_fps = Fraction(fps) if fps else (edit.fps if edit else None)
|
|
115
|
+
if lufs is None and edit and edit.lufs is not None:
|
|
116
|
+
lufs = edit.lufs
|
|
117
|
+
if true_peak is None:
|
|
118
|
+
true_peak = edit.true_peak if edit else -1.0
|
|
119
|
+
require_audio = require_audio or bool(edit and (edit.audio or any(c.audio and c.has_audio for c in edit.base)))
|
|
120
|
+
|
|
121
|
+
actual_fps = Fraction(video['fps'])
|
|
122
|
+
frame_time = float(1 / actual_fps)
|
|
123
|
+
count = video['frames']
|
|
124
|
+
if expected_frames is not None:
|
|
125
|
+
check('frames', 'PASS' if count == expected_frames else 'FAIL', f'{count} decoded, expected {expected_frames}')
|
|
126
|
+
else:
|
|
127
|
+
check('frames', 'PASS', f'{count} decoded')
|
|
128
|
+
if expected_fps is not None:
|
|
129
|
+
check('fps', 'PASS' if actual_fps == expected_fps else 'FAIL', f'{video["fps"]}, expected {expected_fps}')
|
|
130
|
+
dims = f'{video["width"]}x{video["height"]}'
|
|
131
|
+
if expected_size:
|
|
132
|
+
check('size', 'PASS' if dims == expected_size else 'FAIL', f'{dims}, expected {expected_size}')
|
|
133
|
+
if video['width'] % 2 or video['height'] % 2:
|
|
134
|
+
check('size', 'FAIL', f'{dims}: odd dimensions break yuv420p players')
|
|
135
|
+
check('codec', 'PASS' if video['codec'] == 'h264' else ('WARN' if video['codec'] == 'hevc' else 'FAIL'),
|
|
136
|
+
f'{video["codec"]} {video["profile"] or ""}'.strip() + ('' if video['codec'] == 'h264' else ' (deliver H.264 for widest playback)'))
|
|
137
|
+
check('pix_fmt', 'PASS' if video['pix_fmt'] == 'yuv420p' else 'FAIL', f'{video["pix_fmt"]}' + ('' if video['pix_fmt'] == 'yuv420p' else ' (players and platforms expect yuv420p)'))
|
|
138
|
+
check('sar', 'PASS' if video['sar'] == '1:1' else 'FAIL', f'{video["sar"]}' + ('' if video['sar'] == '1:1' else ' (non-square pixels display stretched)'))
|
|
139
|
+
tags = (video['color_space'], video['color_primaries'], video['color_transfer'])
|
|
140
|
+
check('color', 'PASS' if tags == ('bt709', 'bt709', 'bt709') else 'WARN', 'space/primaries/transfer = ' + '/'.join(str(t) for t in tags))
|
|
141
|
+
if video['codec'] == 'h264':
|
|
142
|
+
fixed = _fixed_frame_rate(path)
|
|
143
|
+
if fixed is not None:
|
|
144
|
+
check('fixed_frame_rate', 'PASS' if fixed else 'WARN', 'SPS fixed_frame_rate_flag=1' if fixed else
|
|
145
|
+
'SPS fixed_frame_rate_flag=0: some players time frames from the VUI and show the wrong length (rn finish fixes it)')
|
|
146
|
+
if info['faststart'] is not None:
|
|
147
|
+
check('faststart', 'PASS' if info['faststart'] else 'FAIL', 'moov before mdat' if info['faststart'] else 'moov at the end: remux with -movflags +faststart')
|
|
148
|
+
|
|
149
|
+
video_seconds = count * frame_time
|
|
150
|
+
if audio:
|
|
151
|
+
# Matroska/WebM carry no per-stream duration; the container's is the best available then.
|
|
152
|
+
audio_seconds = audio['duration'] if audio['duration'] is not None else info['duration']
|
|
153
|
+
if audio_seconds is None:
|
|
154
|
+
check('av_duration', 'WARN', f'video {video_seconds:.3f}s, audio duration unknown')
|
|
155
|
+
else:
|
|
156
|
+
drift = abs(audio_seconds - video_seconds)
|
|
157
|
+
check('av_duration', 'PASS' if drift <= max(frame_time, 0.05) else 'WARN',
|
|
158
|
+
f'video {video_seconds:.3f}s, audio {audio_seconds:.3f}s')
|
|
159
|
+
loud = measure(path)
|
|
160
|
+
tp, integrated = loud['true_peak'], loud['lufs']
|
|
161
|
+
if tp is not None:
|
|
162
|
+
check('true_peak', 'PASS' if tp <= true_peak else 'FAIL', f'{tp:.1f} dBTP (limit {true_peak:.1f})')
|
|
163
|
+
if integrated is not None and integrated != float('-inf'):
|
|
164
|
+
if lufs is not None:
|
|
165
|
+
ok = abs(integrated - lufs) <= lufs_tolerance
|
|
166
|
+
check('loudness', 'PASS' if ok else 'WARN', f'{integrated:.1f} LUFS (target {lufs:.1f} ±{lufs_tolerance:g}), LRA {loud["lra"]}')
|
|
167
|
+
else:
|
|
168
|
+
check('loudness', 'PASS', f'{integrated:.1f} LUFS, LRA {loud["lra"]}')
|
|
169
|
+
sound = _sound(path, video_seconds)
|
|
170
|
+
silence, dropped = keep('dead_air', sound['silence'], lambda f: f)
|
|
171
|
+
check('dead_air', 'WARN' if silence else 'PASS', note(
|
|
172
|
+
', '.join(f'{a:.2f}-{b:.2f}s' for a, b in silence) + ' below -70 dBFS (fill with a tail, room tone or bed)'
|
|
173
|
+
if silence else 'no digital silence inside the mix', dropped))
|
|
174
|
+
jumps, dropped = keep('level_jumps', sound['jumps'], lambda f: (f[0] - 0.1, f[0]))
|
|
175
|
+
check('level_jumps', 'WARN' if jumps else 'PASS', note(
|
|
176
|
+
', '.join(f'{t:.1f}s {a:.0f}->{b:.0f} dB' for t, a, b in jumps[:8]) + (f' (+{len(jumps) - 8} more)' if len(jumps) > 8 else '')
|
|
177
|
+
if jumps else f'no jump >= {JUMP_DB} dB between 100 ms windows', dropped))
|
|
178
|
+
from .signal import clicks, pcm
|
|
179
|
+
pops, dropped = keep('clicks', clicks(pcm(path)), lambda f: (f, f))
|
|
180
|
+
check('clicks', 'WARN' if pops else 'PASS', note(
|
|
181
|
+
', '.join(f'{t:.3f}s' for t in pops[:8]) + ' (a lone sample step: an edit without a fade, or a glitch)'
|
|
182
|
+
if pops else 'no lone sample steps', dropped))
|
|
183
|
+
else:
|
|
184
|
+
check('loudness', 'WARN', 'audio track is silent')
|
|
185
|
+
else:
|
|
186
|
+
check('audio', 'FAIL' if require_audio else 'WARN', 'no audio track')
|
|
187
|
+
container = info['duration'] or 0
|
|
188
|
+
if container > video_seconds + max(frame_time, 0.05):
|
|
189
|
+
check('container_duration', 'WARN', f'container {container:.3f}s > video {video_seconds:.3f}s (a longer stream stretches playback)')
|
|
190
|
+
|
|
191
|
+
scan = _scan(path)
|
|
192
|
+
black = [b for b in scan['black'] if not _covered(b, _intended(edit, 'black'), video_seconds)]
|
|
193
|
+
black, dropped = keep('black_frames', black, lambda f: f)
|
|
194
|
+
check('black_frames', 'WARN' if black else 'PASS',
|
|
195
|
+
note(', '.join(f'{a:.2f}-{b:.2f}s' for a, b in black) if black else 'none outside intended color clips', dropped))
|
|
196
|
+
freezes = [f for f in scan['freeze'] if not _covered(f, _intended(edit, 'freeze'), video_seconds)]
|
|
197
|
+
freezes, dropped = keep('frozen_frames', freezes, lambda f: (f[0], f[1] if f[1] is not None else video_seconds))
|
|
198
|
+
check('frozen_frames', 'WARN' if freezes else 'PASS', note(
|
|
199
|
+
', '.join(f'{a:.2f}-{(b if b is not None else video_seconds):.2f}s' for a, b in freezes) if freezes else 'none outside stills', dropped))
|
|
200
|
+
if scan['crop']:
|
|
201
|
+
w, h, x, y = scan['crop']
|
|
202
|
+
bars = w < video['width'] - 8 or h < video['height'] - 8
|
|
203
|
+
check('black_bars', 'WARN' if bars else 'PASS', f'picture area {w}x{h}+{x}+{y} of {dims}' if bars else 'full frame used')
|
|
204
|
+
|
|
205
|
+
for item in checks: # a whole-check accept (no "at") turns a WARN into an accepted PASS; a FAIL is never waived
|
|
206
|
+
waiver = next((a for a in accepts if a['check'] == item['check'] and 'at' not in a), None)
|
|
207
|
+
if item['status'] == 'WARN' and waiver:
|
|
208
|
+
item['status'], item['accepted'] = 'PASS', waiver.get('note', '')
|
|
209
|
+
item['detail'] += f' (accepted: {waiver.get("note") or "intended"})'
|
|
210
|
+
failed = [c for c in checks if c['status'] == 'FAIL']
|
|
211
|
+
warned = [c for c in checks if c['status'] == 'WARN']
|
|
212
|
+
return {'path': path, 'status': 'FAIL' if failed else 'PASS', 'failed': len(failed), 'warnings': len(warned), 'checks': checks}
|
rn/ref.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""rn ref: the measurable half of breaking down a reference edit, in one folder. Shot boundaries (with suspects to
|
|
2
|
+
check by eye), shot lengths, the audio facts from rn listen, and contact sheets covering every frame (or every Nth),
|
|
3
|
+
paged so each page can be viewed. What a shot does and why it works is for you to read off the sheets; nothing here
|
|
4
|
+
interprets: GUIDE.md, copied into the folder, is how to read it into breakdown.md. Not yet measured: on-screen text (OCR)."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
from fractions import Fraction
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .cuts import DEFAULT_THRESHOLD, cuts_command
|
|
13
|
+
from .ff import RnError, probe
|
|
14
|
+
from .frames import sheet_command
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ref_command(path: str, *, out: str | None, every: int, transcribe: bool, language: str | None, model: str,
|
|
18
|
+
threshold: float = DEFAULT_THRESHOLD, width: int = 192) -> dict:
|
|
19
|
+
info = probe(path, exact=True)
|
|
20
|
+
if not info['video'] or info['video']['still']:
|
|
21
|
+
raise RnError(f'{path}: not a video')
|
|
22
|
+
folder = Path(out) if out else Path('ref') / Path(path).stem
|
|
23
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
cuts = cuts_command(path, threshold=threshold, min_gap=0.3, sheet=str(folder / 'cuts.jpg'), max_kb=125)
|
|
25
|
+
frames = info['video']['frames']
|
|
26
|
+
coverage = sheet_command([path], out=str(folder / 'frames.jpg'), every=every, rate=None, indices=None, seconds=None,
|
|
27
|
+
count=None, width=width, columns=6, max_kb=125)['pages']
|
|
28
|
+
listen = None
|
|
29
|
+
if info['audio']:
|
|
30
|
+
from .listen import listen_command
|
|
31
|
+
listen = listen_command(path, out=str(folder / 'listen.jpg'), transcribe=transcribe, language=language, model=model)
|
|
32
|
+
shutil.copyfile(Path(__file__).with_name('ref_guide.md'), folder / 'GUIDE.md')
|
|
33
|
+
result = {
|
|
34
|
+
'path': path,
|
|
35
|
+
'folder': str(folder),
|
|
36
|
+
'probe': info,
|
|
37
|
+
'shots': cuts['shots'],
|
|
38
|
+
'cuts': cuts['cuts'],
|
|
39
|
+
'suspects': cuts['suspects'],
|
|
40
|
+
'cut_sheets': [p['path'] for p in cuts.get('sheet', [])],
|
|
41
|
+
'coverage': {'frames': frames, 'every': every, 'sampled': sum(len(p['indices']) for p in coverage),
|
|
42
|
+
'pages': [p['path'] for p in coverage],
|
|
43
|
+
'page_frames': [[p['indices'][0], p['indices'][-1]] for p in coverage]},
|
|
44
|
+
'listen': listen,
|
|
45
|
+
'cut_sync': cut_sync(cuts['cuts'], listen['onsets'] if listen else [], Fraction(cuts['fps'])),
|
|
46
|
+
}
|
|
47
|
+
(folder / 'ref.json').write_text(json.dumps(result, indent=1, ensure_ascii=False, default=str))
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cut_sync(cuts: list[dict], onsets: list[float], fps: Fraction, tolerance: int = 2) -> dict | None:
|
|
52
|
+
"""How tightly the cuts follow the audio: each cut's offset in frames to the nearest onset (negative = the cut
|
|
53
|
+
leads the sound) and the share within +-tolerance frames. A high share means the edit is cut on the sound."""
|
|
54
|
+
if not cuts or not onsets:
|
|
55
|
+
return None
|
|
56
|
+
offsets = [round(float((cut['time'] - min(onsets, key=lambda t: abs(t - cut['time']))) * fps)) for cut in cuts]
|
|
57
|
+
return {'share': round(sum(abs(o) <= tolerance for o in offsets) / len(offsets), 2), 'tolerance_frames': tolerance,
|
|
58
|
+
'offsets_frames': offsets}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def summary_lines(result: dict) -> list[str]:
|
|
62
|
+
shots = result['shots']
|
|
63
|
+
lengths = ', '.join(f'{s["duration"]:.2f}' for s in shots[:12]) + (' ...' if len(shots) > 12 else '')
|
|
64
|
+
coverage = result['coverage']
|
|
65
|
+
lines = [f'{result["folder"]}/ref.json',
|
|
66
|
+
f'{len(shots)} shots ({lengths} s), {len(result["suspects"])} suspect cuts to check: {", ".join(result["cut_sheets"]) or "-"}',
|
|
67
|
+
f'frame coverage: {coverage["sampled"]} of {coverage["frames"]} frames (every {coverage["every"]}) on {len(coverage["pages"])} pages']
|
|
68
|
+
if result['listen']:
|
|
69
|
+
from .listen import summary_lines as listen_lines
|
|
70
|
+
lines += listen_lines(result['listen'])
|
|
71
|
+
if result['cut_sync']:
|
|
72
|
+
lines.append(f'cuts on an onset (+-2 frames): {result["cut_sync"]["share"]:.0%}')
|
|
73
|
+
lines.append(f'next: read {result["folder"]}/GUIDE.md and write {result["folder"]}/breakdown.md')
|
|
74
|
+
return lines
|