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.
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