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/ref_guide.md ADDED
@@ -0,0 +1,245 @@
1
+ # Reading a reference edit
2
+
3
+ `rn ref` measured this reference: `ref.json` (probe, shots, cuts, suspects, listen, cut_sync, coverage), `cuts.jpg`
4
+ (both sides of every cut and suspect), `frames*.jpg` (the coverage pages) and `listen.jpg`. This guide is how to turn
5
+ that into `breakdown.md` in this folder. Reading costs no credits and decides most of what gets spent later, so do it
6
+ fully before proposing any generation.
7
+
8
+ **The sheets are the truth; the detector only proposes.** On whip edits, strobe edits and dark or grainy footage a
9
+ scene detector routinely finds well under half the real cuts, and on boiling grain it fires every couple of frames.
10
+ Every number you write comes from a frame you looked at.
11
+
12
+ ## 1. Order of work
13
+
14
+ 1. `cuts.jpg`: confirm or reject every cut and suspect. Merged near-duplicates are listed as suspects on purpose.
15
+ 2. Overview: look at a few pages first and write a draft of the trick line (§3).
16
+ 3. Every coverage page, in order, first frame to last (§2). Keep the event log (§2.3) as you go.
17
+ 4. Zoom into anything unclear (§2.2). Never settle an ambiguity from a small tile.
18
+ 5. Sound: `listen.jpg` and `ref.json` `listen` / `cut_sync` (§5). Re-run `rn ref --transcribe` if words matter.
19
+ 6. People, text, look (§4, §6), then meaning and roles (§7).
20
+ 7. Write `breakdown.md` (§8), check it against §10, and tell the user what you found (§9).
21
+
22
+ With `--every 1` (the default) the coverage pages hold every decoded frame. If you used `--every N`, the gaps are not
23
+ inspected: fill them with `rn sheet ref.mp4 --indices ...` before calling the read complete.
24
+
25
+ ## 2. Reading the pages
26
+
27
+ ### 2.1 Budget and labels
28
+ - One `image_paths` view takes at most 4 images and 512 KB in total. `rn` pages are under 125 KB, so four per call.
29
+ Check sizes on anything you made yourself; split a batch rather than skip a page.
30
+ - Tiles are labelled with the absolute frame index and timecode. If you build a sheet with ffmpeg yourself, put
31
+ `drawtext ... %{n}` before `select`, or every page restarts at 0 and every frame number you log is wrong.
32
+ - Select frames by index (`rn frame --index`, `rn sheet --indices`), never with ffmpeg's input `-ss`: it lands a
33
+ frame or two off.
34
+
35
+ ### 2.2 Zooming in
36
+ - `rn sheet ref.mp4 --indices 35,36,37,38,39,40 --width 540 --columns 3`: consecutive frames, large.
37
+ - `rn sheet ref.mp4 --around 3.37 --span 4`: both sides of a cut, beat or transition.
38
+ - `rn sheet ref.mp4 --indices ... --diff`: frame-to-frame difference; shows held frames, flicker and hidden cuts.
39
+ - `rn frame ref.mp4 --index 52 -o f52.png`: one frame at native pixels for text, texture and edges.
40
+
41
+ ### 2.3 Event log
42
+ One line per event, by frame, saved as `events.txt` next to the breakdown so later turns and other agents can reuse it:
43
+
44
+ ```
45
+ f0-2 white flash in, product already centred
46
+ f3 CUT close-up of the label, slow push-in starts
47
+ f11-14 price tag slides in from the right, lands f14 with an overshoot
48
+ f18 CUT hidden in a 3-frame blur pulse (f17-19); new angle, same table
49
+ f26-27 1-frame black insert at f26, NOT a new shot
50
+ ```
51
+
52
+ ### 2.4 Coverage
53
+ - Record each range you actually viewed as `[first, last]`. `ref.json` `coverage.page_frames` lists each page's
54
+ range. Their union must cover 0 to frames-1 with no gaps. A page that was generated but not looked at does not count.
55
+ - Also keep one line per second `[k, k+1)`, including the final partial second: what changes in it (cut, text,
56
+ effect, camera, sound), or "no change".
57
+ - If something stops you from finishing (time, a broken file), name the ranges you did not inspect. Never call a
58
+ partial read complete.
59
+
60
+ ## 3. The trick line (write it first)
61
+
62
+ After the overview, before per-shot work, write one sentence:
63
+
64
+ > **The trick of this reference is** <the device> **on** <what timing or structure> **so that** <what the viewer
65
+ > feels or gets>.
66
+
67
+ List the frames where it happens. Refine it after the full read, but keep it one sentence. Tests:
68
+ 1. Could a stranger rebuild the idea from the sentence alone?
69
+ 2. Does it say what changes over time, not only how it looks?
70
+ 3. Would a remake be obviously wrong without it?
71
+
72
+ Copying the timing while losing the device is a failed remake; so is copying the device without serving the new
73
+ subject. If there is no device beyond "fast cuts on the beat", say so: the trick is then the rhythm plus the look.
74
+
75
+ The trick tells you how each shot should be made, before any model is chosen:
76
+
77
+ | What the reference is | Make it with |
78
+ |---|---|
79
+ | New people or worlds under styled graphics | generated clips + graphics in the sandbox |
80
+ | The user's own footage, re-cut | the sandbox only, no generation |
81
+ | A montage of real events or people | the user's or licensed footage + sandbox; don't fake real events |
82
+ | A photo series / stop motion: locked camera, one detail changing every 1-2 frames | stills + code, not video |
83
+ | Typography and shapes only | code (`rn capture`), no generation |
84
+ | The same edit with the user's product or person swapped in | per shot: reuse, still + code, or generate |
85
+
86
+ Mixed references are normal: pick the dominant kind, then decide per shot.
87
+
88
+ ## 4. What to look for on every page
89
+
90
+ ### 4.1 Cuts the detector misses
91
+ A cut is the first frame of the new shot. Flash, blur and burn frames belong to the shot they sit in.
92
+
93
+ | Kind | On the sheet |
94
+ |---|---|
95
+ | Whip / smear | 2-3 smeared frames; the content behind the smear changes mid-way; often straddles the beat |
96
+ | Hidden in a transition | burn, blur, mosaic or flash frames with a different pose or framing on each side |
97
+ | Dark | both sides near black; only shapes change |
98
+ | Same-tone jump cut | same place and palette, different pose or framing |
99
+ | 1-2 frame insert | one foreign frame: black, white, a colour, another shot, a ghost blend |
100
+ | Strobe | the picture alternates every 1-3 frames |
101
+ | Micro-cut burst | a run of tiny shots on a 4-6 frame grid: log it as one shot with the sub-shots in notes |
102
+
103
+ ### 4.2 False cuts the detector adds
104
+ Grain or photocopy boil, flash frames, exposure flashes, text or card pops, light flicker, camera shake, letters
105
+ flying out.
106
+
107
+ ### 4.3 Transitions
108
+ Name each with its frames and say whether it hides a cut: hard cut, whip, overexposure burn, white or black frames,
109
+ colour flash, mosaic, glitch, blur pulse, dissolve, invert, zoom punch, slide, wipe, split strips, mirror, match cut.
110
+ **Ink / matte wipes** are often mislogged as a smear and rebuilt as a blur: both pictures stay sharp, a ragged or
111
+ organic edge (brush, ink, torn paper, smoke) travels across, and mid-wipe one frame holds both shots at full contrast.
112
+ A dissolve shows both everywhere at reduced contrast instead.
113
+
114
+ ### 4.4 Text
115
+ For every word or block: first frame, first fully-on frame, start of exit, last frame; the animation (fade length in
116
+ frames, pop, typewriter, slam, write-on, smear-out); whether the subject passes in front; whether it crosses a cut.
117
+ Measure on native frames (`rn frame`): fades read from small tiles are often 1-2 frames off.
118
+ - Box and size: the tight box of the letters, size as cap height / frame height, centre as fractions of the frame.
119
+ - Colour from the letter core, not the edge; note stroke, shadow, glow.
120
+ - Font: describe the class (condensed grotesk caps, didone serif, bold italic sans...) and pick the nearest family
121
+ that `rn fonts` shows with the weight really installed. Don't claim to have identified the exact font.
122
+
123
+ ### 4.5 Layers (everything that is not the footage)
124
+ Outlines and halos around the subject, panels and picture-in-picture, shapes, stickers and doodles, borders,
125
+ windows, letterbox, watermark, light leaks, particles, HUD. For each: under or over the subject, and whether it is
126
+ static or follows the subject, the face or the camera. **Count stickers and doodles per second and note their size
127
+ relative to the subject**: density and scale are what viewers notice when a remake looks thinner than the reference.
128
+
129
+ ### 4.6 Look
130
+ - **Clean**: natural colour, no visible grain, no vignette, no cast beyond the scene's light. Add nothing.
131
+ - **Stylised**: B/W, threshold, duotone, one accent colour, visible or boiling grain (size, boil rate), halation,
132
+ light leaks, a hard curve, one cast over everything. Measure it on native crops of the darkest, brightest, skin and
133
+ text frames, plus every other look state the per-second log shows.
134
+ - Generated clips usually come back less saturated than stock footage: compare against the reference, don't assume.
135
+
136
+ ### 4.7 Camera
137
+ Separate moves in the footage (pan, tilt, push, orbit, handheld) from moves added in the edit (zoom punch, eased
138
+ push-in, shake, mirror, rotation). Edit moves are rebuilt in the sandbox at the output fps; don't also ask a video
139
+ model for them, or the move doubles.
140
+
141
+ ### 4.8 Cadence and speed
142
+ Count unique frames per shot (`--diff` shows held frames as black tiles): native, 24p in 30p (every 5th repeated),
143
+ 2:3 pulldown, stepped (e.g. ~18 unique fps in a 30p file), interpolated slow motion, frame-blend retime, freeze,
144
+ speed ramp (note frame and speed). Generated clips come at their model's fps (`rn probe`): a smooth 60p reference
145
+ shot rebuilt from a 24 fps clip loses images that code cannot bring back, and a stepped reference rebuilt smooth
146
+ looks wrong too. Note both in the plan.
147
+
148
+ ## 5. Sound
149
+
150
+ `rn listen` measures; it cannot hear. Treat its numbers as facts and its tempo as a guess.
151
+ - **BPM check**: express the shot lengths in beats at the detected BPM and at x2, x1/2, x3/4 and x4/3. The right
152
+ tempo puts most of them near whole or half beats. On chants or VO, cross-check with word starts. Write down the
153
+ check, not just the number.
154
+ - **Phase**: do cuts lead the beat (`cut_sync.offsets_frames` mostly negative) or lag it?
155
+ - **Cut on the sound**: `cut_sync.share` is the share of cuts within 2 frames of an onset. Around 0.7 or more, the
156
+ edit is cut on the sound and the remake's cuts must follow its own track the same way. Low: the cuts follow the
157
+ picture or the words.
158
+ - **Hidden music edits**: a sudden change in energy or timbre off the bar grid, a section starting mid-phrase, a
159
+ skipped or doubled beat. Note them as suspected splices; they matter if the user swaps the track.
160
+ - **Sound effects**: whooshes, booms, risers, shutters, ducks under VO, with times. They are part of the edit.
161
+ - **Words**: Whisper's word times run late on TTS and merge chanted words; check them against the loudness curve.
162
+ - **Loudness**: integrated LUFS and true peak, as the QA reference.
163
+
164
+ ## 6. People in each shot
165
+
166
+ For every shot: how visible the person is (`face clear`, `face partial`, `body only` for backs, helmets, hands or
167
+ silhouettes, `none` when nobody is there), the shot size (ECU, CU, MCU, MS, MWS, WS, EWS, insert, graphic), who it
168
+ is when there are several, and who must stay themselves. A hidden face does not exempt a shot from a requested
169
+ person or product swap: continuity across the back view and the close-up is part of the job.
170
+
171
+ ## 7. Meaning and roles
172
+
173
+ - For every graphic and text event, write what it means relative to the words, the beat or the story, and what it
174
+ becomes in the remake. A remake that keeps the shapes but loses the meaning reads as random graphics: lyrics on
175
+ their syllables are the song's words, not decoration; a sentence spread across shots breaks if one word is lost.
176
+ - Give each shot a role from what you saw: hook, identity, action, accent, reveal, consequence, transition, breath,
177
+ payoff, closure. Don't invent a plot a non-narrative reference doesn't have.
178
+ - **Missing-role inventory**: for each role the new video needs, what the user already supplied that you actually
179
+ looked at, what is missing, and how to fill it. Generate only what is missing, not a fresh version of every shot.
180
+ A photo gives identity and design; it does not contain an action it doesn't show.
181
+ - When the user's subject differs from the reference's (a nation vs one player, a range vs one product), map the
182
+ reference's sections onto the user's subject first; don't keep the structure and give the subject a corner of it.
183
+
184
+ ## 8. breakdown.md
185
+
186
+ ```markdown
187
+ # <reference name>: breakdown
188
+ Source: <path>, <w>x<h>, <fps as a fraction>, <frames> frames, video <s> s, audio <s> s
189
+ Trick: The trick of this reference is ... (frames: ...)
190
+ Made with: <the §3 row, then per-shot exceptions>
191
+ Look: clean | stylised: <grain, grade, cast, measured on frames ...>
192
+ Palette: <hex - role>, ...
193
+ Fonts: <class -> nearest installed family/weight>, ...
194
+ Sound: <bpm and how it was checked>, cut on sound <share>, phase <lead/lag>, splices <t or none>,
195
+ sfx <t type>, speech <none/vo/lyrics/chant>, <LUFS> LUFS / <dBTP> dBTP
196
+ Coverage: inspected [0, N-1] in ranges <...>; per-second log in events.txt
197
+
198
+ | shot | frames | content | person | size | camera (footage / edit) | cadence | in / out transition | effects, layers | role |
199
+ |---|---|---|---|---|---|---|---|---|---|
200
+
201
+ | text | frames in / full / out | words | font class | size % H | centre | colour | animation | front / behind |
202
+ |---|---|---|---|---|---|---|---|---|
203
+
204
+ Meaning: <element -> what it means -> what it becomes>
205
+ Missing roles: <role, what the user supplied, gap, plan>
206
+ Doubts: <what you could not decide, with frames>
207
+ ```
208
+
209
+ Shots are contiguous: the first starts at frame 0, each starts one after the previous ends, the last ends at
210
+ frames-1. Text inside a shot stays inside its frames unless marked as crossing a cut. Keep observations ("the
211
+ reference does X at f12") apart from proposals ("we could do Y"); never present an idea as something seen.
212
+
213
+ ## 9. Splitting the read across agents
214
+
215
+ Only for long or dense references (more than ~25 shots, longer than ~20 s, or 100+ text and sticker events) and only
216
+ when the host can run subagents with these tools. Otherwise read it yourself, section by section: one careful reader
217
+ beats several careless ones, and every agent costs tokens.
218
+ - Before splitting: finish §1 steps 1-2 yourself (cuts confirmed, trick line drafted). Agents work inside your frame.
219
+ - Split by section where the reference repeats a scheme, otherwise 3-4 shots per agent; at most ~6 agents, each
220
+ with its frame range plus 2 frames of overlap on each side.
221
+ - Each agent: works only in `ref/<name>/agents/<its id>/`, uses `rn sheet` / `rn frame` for zooms and never re-runs
222
+ `rn ref` (it rewrites the shared folder), only foreground calls, never `restart`, no credit-spending or upload tools.
223
+ - Each returns: its shot rows and text rows, any boundary changes with the evidence frame, its event-log lines, its
224
+ doubts, and the ranges it actually viewed.
225
+ - After: merge, settle the overlaps yourself on a zoom strip, and check the union of viewed ranges covers every frame.
226
+
227
+ ## 10. Before you call the read done
228
+
229
+ - [ ] Every frame 0..N-1 viewed; the viewed ranges have no gaps; the per-second log includes the partial last second.
230
+ - [ ] Every cut and suspect checked against grain, flashes, text pops and flying letters; 1-frame inserts found.
231
+ - [ ] Ink or matte wipes logged as wipes, not smears; transitions that hide cuts marked.
232
+ - [ ] Cadence counted per shot, not assumed; fps kept as a fraction (29.97 is 30000/1001).
233
+ - [ ] Letterboxed or windowed references measured inside the picture (bars skew the look and fake cuts).
234
+ - [ ] BPM checked against shot lengths; cut-on-sound share and phase noted; suspected splices noted.
235
+ - [ ] Text frames confirmed on native frames; sticker density counted per second.
236
+ - [ ] Video length taken from frames, not from the container or the audio.
237
+ - [ ] Nothing paid during the read. Anything that can only be learned by generating goes into the plan, priced.
238
+
239
+ ## 11. Telling the user
240
+
241
+ 3-5 plain lines in the user's language: what the reference does (the trick), how the remake will be made, what stays
242
+ the same and what changes, and anything you could not inspect. No pipeline words or bare shot numbers: describe shots
243
+ ("the close-up of the label", "the back view on the stairs"). If you refer to shots by number, upload the labelled
244
+ cut sheet with it. Then follow the active workflow skill: plan, estimate, and wait for the user's approval before
245
+ anything that spends credits.
rn/signal.py ADDED
@@ -0,0 +1,190 @@
1
+ """rn signal: a video's and its sound's measurable state, frame by frame, as numbers. One series per quantity on one
2
+ shared clock (the video's frames; 30 per second for audio alone), so flashes, freezes, pacing, beats and music-driven
3
+ motion are all read from the same data instead of a detector per question. Facts only.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+ from .ff import RnError, probe, run
14
+
15
+ SR = 48000
16
+ BANDS = {'low': (20, 250), 'mid': (250, 4000), 'high': (4000, 20000)}
17
+ FLASH = 0.2 # luma jump (0..1) up and back within FLASH_FRAMES
18
+ FLASH_FRAMES = 4
19
+ CLICK_WINDOW = 0.05 # seconds
20
+ _KEY = re.compile(r'lavfi\.signalstats\.(YAVG|YDIF|SATAVG)=([0-9.]+)')
21
+
22
+
23
+ def pcm(path: str | Path, rate: int = SR) -> np.ndarray:
24
+ """The first audio stream as mono float32 at `rate`."""
25
+ result = subprocess.run(['ffmpeg', '-hide_banner', '-v', 'error', '-nostdin', '-i', str(path), '-map', '0:a:0', '-ac', '1',
26
+ '-ar', str(rate), '-f', 'f32le', '-'], capture_output=True)
27
+ if result.returncode != 0:
28
+ raise RnError(f'{path}: audio decode failed: {result.stderr.decode(errors="replace").strip().splitlines()[-1:]}')
29
+ return np.frombuffer(result.stdout, dtype=np.float32)
30
+
31
+
32
+ def clicks(samples: np.ndarray, rate: int = SR, *, edge: float = 0.05) -> list[float]:
33
+ """Lone sample steps: a step more than 8x the 99.5th percentile of its 50 ms window and 2.5x the window's next largest.
34
+ The head and tail (`edge` seconds) are skipped: a file starting or ending mid-wave is not a click in the edit."""
35
+ size = int(rate * CLICK_WINDOW)
36
+ steps = np.abs(np.diff(samples))
37
+ found = []
38
+ for start in range(int(edge * rate), len(steps) - size - int(edge * rate), size):
39
+ window = steps[start:start + size]
40
+ top = np.partition(window, -2)[-2:]
41
+ largest, second = top.max(), top.min()
42
+ if largest > 0.05 and largest > 8 * np.percentile(window, 99.5) and largest > 2.5 * second:
43
+ seconds = round((start + int(window.argmax())) / rate, 4)
44
+ if not found or seconds - found[-1] > 0.01: # an impulse steps up and down, maybe across two windows
45
+ found.append(seconds)
46
+ return found
47
+
48
+
49
+ def _video(path: str) -> tuple[list[float], list[float], list[float]]:
50
+ result = run(['ffmpeg', '-i', path, '-map', '0:v:0', '-vf', 'signalstats,metadata=print:file=-', '-f', 'null', '-'])
51
+ luma, diff, sat = [], [], []
52
+ for line in result.stdout.splitlines():
53
+ if line.startswith('frame:'):
54
+ luma.append(0.0), diff.append(0.0), sat.append(0.0)
55
+ elif (match := _KEY.search(line)) and luma:
56
+ value = float(match.group(2))
57
+ if match.group(1) == 'YAVG':
58
+ luma[-1] = round(min(1.0, max(0.0, (value - 16) / 219)), 4) # limited range -> 0..1
59
+ elif match.group(1) == 'YDIF':
60
+ diff[-1] = round(value / 255, 4)
61
+ else:
62
+ sat[-1] = round(value / 128, 4)
63
+ return luma, diff, sat
64
+
65
+
66
+ def _onset(samples: np.ndarray, n: int, fps: float) -> list[float]:
67
+ """Spectral flux per frame: the mean rise in dB over 32 log-spaced bands (like a mel scale, so a kick is not diluted
68
+ by a thousand empty bins) between STFT frames (2048 / hop 512), the largest within each output frame. A steady tone
69
+ reads ~0 whatever its phase; a hit or a new note reads several dB."""
70
+ n_fft, hop = 2048, 512
71
+ if len(samples) < n_fft + hop:
72
+ return [0.0] * n
73
+ window = np.hanning(n_fft)
74
+ edges = np.unique(np.geomspace(1, n_fft // 2 + 1, 33).astype(int))
75
+ band_of = np.digitize(np.arange(n_fft // 2 + 1), edges[1:-1])
76
+ count = 1 + (len(samples) - n_fft) // hop
77
+ flux = np.zeros(count)
78
+ previous = None
79
+ for first in range(0, count, 1024): # bounded memory for long files
80
+ idx = np.arange(n_fft)[None, :] + hop * np.arange(first, min(count, first + 1024))[:, None]
81
+ power = np.abs(np.fft.rfft(samples[idx] * window, axis=1)) ** 2
82
+ bands = np.stack([power[:, band_of == b].sum(axis=1) for b in range(band_of.max() + 1)], axis=1)
83
+ db = 10 * np.log10(np.maximum(bands, 1e-12))
84
+ db = np.maximum(db, db.max() - 60) # leakage far below the content flickers; floor it (librosa's top_db)
85
+ # The first STFT frame has nothing before it; later chunks continue from the previous chunk's last frame.
86
+ start = first + 1 if previous is None else first
87
+ stacked = db if previous is None else np.vstack([previous, db])
88
+ flux[start:first + len(db)] = np.maximum(np.diff(stacked, axis=0), 0).mean(axis=1)
89
+ previous = db[-1:]
90
+ centre = (np.arange(count) * hop + n_fft / 2) / SR
91
+ which = np.minimum((centre * fps).astype(int), n - 1)
92
+ per_frame = np.zeros(n)
93
+ np.maximum.at(per_frame, which, flux)
94
+ return [round(float(v), 3) for v in per_frame]
95
+
96
+
97
+ def _audio(samples: np.ndarray, frames: int, fps: float) -> dict:
98
+ hop = SR / fps
99
+ n = frames or int(len(samples) / hop)
100
+ size = int(round(hop))
101
+ freqs = np.fft.rfftfreq(size, 1 / SR)
102
+ window = np.hanning(size)
103
+ rms, bands = [], {name: [] for name in BANDS}
104
+ for k in range(n):
105
+ chunk = samples[int(k * hop):int(k * hop) + size]
106
+ if len(chunk) < size:
107
+ chunk = np.pad(chunk, (0, size - len(chunk)))
108
+ level = float(np.sqrt(np.mean(chunk ** 2)))
109
+ rms.append(round(20 * np.log10(level), 2) if level > 1e-7 else -140.0)
110
+ spectrum = np.abs(np.fft.rfft(chunk * window))
111
+ for name, (lo, hi) in BANDS.items():
112
+ energy = float(np.sqrt(np.sum(spectrum[(freqs >= lo) & (freqs < hi)] ** 2)) / size)
113
+ bands[name].append(round(20 * np.log10(energy), 2) if energy > 1e-7 else -140.0)
114
+ return {'rms_db': rms, **{f'{name}_db': values for name, values in bands.items()}, 'onset_db': _onset(samples, n, fps)}
115
+
116
+
117
+ def _flashes(luma: list[float]) -> list[int]:
118
+ """Frames where the picture jumps brighter and comes back within a few frames (a white flash, a strobe hit)."""
119
+ found = []
120
+ for i in range(1, len(luma)):
121
+ if luma[i] - luma[i - 1] >= FLASH and any(luma[i] - luma[j] >= FLASH for j in range(i + 1, min(len(luma), i + 1 + FLASH_FRAMES))):
122
+ found.append(i)
123
+ return found
124
+
125
+
126
+ def _plot(result: dict, out: Path) -> None:
127
+ import matplotlib
128
+ matplotlib.use('Agg')
129
+ import matplotlib.pyplot as plt
130
+ rows = [k for k in ('video', 'audio') if result.get(k)]
131
+ figure, axes = plt.subplots(len(rows), 1, figsize=(12, 2.4 * len(rows)), sharex=True, squeeze=False)
132
+ t = np.arange(result['frames']) / result['fps']
133
+ for axis, kind in zip(axes[:, 0], rows):
134
+ for name, values in result[kind].items():
135
+ values = np.asarray(values, dtype=float)
136
+ if name == 'onset_db':
137
+ values = np.clip(values / 12, 0, 1) # 0..12 dB of rise -> 0..1
138
+ elif kind == 'audio':
139
+ values = np.clip((values + 60) / 60, 0, 1) # -60..0 dB -> 0..1
140
+ axis.plot(t[:len(values)], values, label=name, linewidth=0.9)
141
+ axis.set_ylim(0, 1.05)
142
+ axis.legend(loc='upper right', fontsize=7, ncol=6)
143
+ for frame in result.get('flashes', []):
144
+ axes[0, 0].axvline(frame / result['fps'], color='orange', linewidth=0.8)
145
+ for seconds in result.get('clicks', []):
146
+ axes[-1, 0].axvline(seconds, color='red', linewidth=0.8)
147
+ axes[-1, 0].set_xlabel('seconds')
148
+ figure.tight_layout()
149
+ out.parent.mkdir(parents=True, exist_ok=True)
150
+ figure.savefig(out, dpi=90)
151
+ plt.close(figure)
152
+
153
+
154
+ def signal_command(path: str, *, out: str | None) -> dict:
155
+ info = probe(path)
156
+ video = info['video'] if info['video'] and not info['video']['still'] else None
157
+ if not video and not info['audio']:
158
+ raise RnError(f'{path}: no video or audio stream')
159
+ result: dict = {'path': path}
160
+ luma = []
161
+ if video:
162
+ luma, diff, sat = _video(path)
163
+ result.update(fps=float(video['fps_float']), frames=len(luma), video={'luma': luma, 'diff': diff, 'sat': sat},
164
+ flashes=_flashes(luma))
165
+ else:
166
+ result.update(fps=30.0, frames=0)
167
+ if info['audio']:
168
+ samples = pcm(path)
169
+ result['audio'] = _audio(samples, result['frames'], result['fps'])
170
+ result['frames'] = result['frames'] or len(result['audio']['rms_db'])
171
+ result['clicks'] = clicks(samples)
172
+ if out:
173
+ _plot(result, Path(out))
174
+ result['image'] = out
175
+ return result
176
+
177
+
178
+ def summary_lines(result: dict) -> list[str]:
179
+ lines = [f'{result["path"]}: {result["frames"]} frames at {result["fps"]:g} fps']
180
+ if 'video' in result:
181
+ luma = result['video']['luma']
182
+ lines.append(f'luma {min(luma):.2f}..{max(luma):.2f} mean diff {sum(result["video"]["diff"]) / len(luma):.3f} '
183
+ f'flashes at frames {result["flashes"]}')
184
+ if 'audio' in result:
185
+ loud = [v for v in result['audio']['rms_db'] if v > -140]
186
+ lines.append(f'audio rms {min(loud, default=-140):.1f}..{max(loud, default=-140):.1f} dB clicks at {result["clicks"]} s')
187
+ if 'image' in result:
188
+ lines.append(f'image {result["image"]}')
189
+ lines.append('series per frame in --json: video luma/diff/sat (0..1), audio rms/low/mid/high (dB), onset (dB of spectral rise)')
190
+ return lines
rn/text.py ADDED
@@ -0,0 +1,112 @@
1
+ """rn text: words on screen and when they are there (tesseract OCR on sampled frames). For reading a reference's titles
2
+ and captions, keeping new text clear of old, or checking a render's text. Measurement only; OCR misreads stylised type,
3
+ so treat the boxes as where to look and the strings as a draft.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import csv
8
+ import io
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from .ff import RnError, probe, source_fps
15
+ from .frames import extract
16
+
17
+ PREFERRED = ('eng', 'chi_sim', 'chi_tra', 'jpn', 'kor')
18
+
19
+
20
+ def languages() -> list[str]:
21
+ if not shutil.which('tesseract'):
22
+ raise RnError('tesseract not found: brew install tesseract (ffmpeg-full brings it), apt install tesseract-ocr '
23
+ '(+ tesseract-ocr-chi-sim for Chinese)')
24
+ listed = subprocess.run(['tesseract', '--list-langs'], capture_output=True, text=True).stdout.split()
25
+ return [lang for lang in listed if not lang.startswith('List') and lang != 'osd']
26
+
27
+
28
+ def _join(words: list[str]) -> str:
29
+ """Tesseract splits CJK into single characters: no space between two CJK characters, a space elsewhere."""
30
+ cjk = lambda c: ord(c) >= 0x2E80 # noqa: E731
31
+ out = words[0]
32
+ for word in words[1:]:
33
+ out += ('' if cjk(out[-1]) and cjk(word[0]) else ' ') + word
34
+ return out
35
+
36
+
37
+ def _read(png: Path, lang: str, min_conf: float) -> list[dict]:
38
+ """Words grouped into lines (tesseract's block/paragraph/line numbers), each with its bounding box."""
39
+ tsv = subprocess.run(['tesseract', str(png), '-', '-l', lang, '--psm', '11', 'tsv'], capture_output=True, text=True)
40
+ if tsv.returncode != 0:
41
+ raise RnError(f'tesseract failed: {tsv.stderr.strip().splitlines()[-1:]}')
42
+ lines: dict[tuple, dict] = {}
43
+ for row in csv.DictReader(io.StringIO(tsv.stdout), delimiter='\t', quoting=csv.QUOTE_NONE):
44
+ word = (row.get('text') or '').strip()
45
+ if not word or float(row['conf']) < min_conf:
46
+ continue
47
+ key = (row['block_num'], row['par_num'], row['line_num'])
48
+ x, y, w, h = (int(row[k]) for k in ('left', 'top', 'width', 'height'))
49
+ line = lines.setdefault(key, {'words': [], 'conf': [], 'box': [x, y, x + w, y + h]})
50
+ line['words'].append(word)
51
+ line['conf'].append(float(row['conf']))
52
+ box = line['box']
53
+ line['box'] = [min(box[0], x), min(box[1], y), max(box[2], x + w), max(box[3], y + h)]
54
+ found = []
55
+ for line in lines.values():
56
+ x0, y0, x1, y1 = line['box']
57
+ found.append({'text': _join(line['words']), 'box': [x0, y0, x1 - x0, y1 - y0], 'conf': round(sum(line['conf']) / len(line['conf']), 1)})
58
+ return sorted(found, key=lambda item: (item['box'][1], item['box'][0]))
59
+
60
+
61
+ def text_command(path: str, *, every: int | None, lang: str | None, min_conf: float) -> dict:
62
+ available = languages()
63
+ lang = lang or '+'.join(l for l in PREFERRED if l in available) or 'eng'
64
+ missing = [l for l in lang.split('+') if l not in available]
65
+ if missing:
66
+ raise RnError(f'tesseract has no {missing} data (installed: {available})')
67
+ info = probe(path)
68
+ video = info['video']
69
+ if not video:
70
+ raise RnError(f'{path}: no picture')
71
+ still = video['still']
72
+ fps = None if still else source_fps(info)
73
+ total = 1 if still else video['frames']
74
+ step = every or (1 if still else max(1, round(float(fps) / 2))) # two reads a second by default
75
+ samples = []
76
+ with tempfile.TemporaryDirectory() as tmp:
77
+ frames = [(0, Path(path))] if still else extract(path, list(range(0, total, step)), Path(tmp))
78
+ for index, png in frames:
79
+ samples.append({'f': index, 't': None if still else round(index / float(fps), 3), 'lines': _read(png, lang, min_conf)})
80
+ # Spans: the same line read in consecutive samples is one appearance.
81
+ spans: list[dict] = []
82
+ open_spans: dict[str, dict] = {}
83
+ for sample in samples:
84
+ seen = set()
85
+ for line in sample['lines']:
86
+ seen.add(line['text'])
87
+ span = open_spans.get(line['text'])
88
+ if span and span['last_frame'] >= sample['f'] - step:
89
+ span['last_frame'] = sample['f']
90
+ else:
91
+ span = {'text': line['text'], 'first_frame': sample['f'], 'last_frame': sample['f'], 'box': line['box']}
92
+ open_spans[line['text']] = span
93
+ spans.append(span)
94
+ for text in [t for t in open_spans if t not in seen]:
95
+ del open_spans[text]
96
+ for span in spans:
97
+ if fps:
98
+ span['start'] = round(span['first_frame'] / float(fps), 3)
99
+ span['end'] = round(min(total, span['last_frame'] + step) / float(fps), 3) # up to the next sample not read
100
+ return {'path': path, 'lang': lang, 'every': step, 'fps': str(fps) if fps else None, 'samples': samples, 'spans': spans}
101
+
102
+
103
+ def summary_lines(result: dict) -> list[str]:
104
+ lines = [f'{result["path"]}: {len(result["samples"])} frames read (every {result["every"]}), {len(result["spans"])} text spans, '
105
+ f'lang {result["lang"]}']
106
+ for span in result['spans'][:40]:
107
+ when = f'{span["start"]:.2f}-{span["end"]:.2f}s' if 'start' in span else 'still'
108
+ x, y, w, h = span['box']
109
+ lines.append(f' {when} "{span["text"]}" at {x},{y} {w}x{h}')
110
+ if len(result['spans']) > 40:
111
+ lines.append(f' ... {len(result["spans"]) - 40} more in --json')
112
+ return lines