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