cutnow 0.1.0__tar.gz

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.
Files changed (38) hide show
  1. cutnow-0.1.0/LICENSE +21 -0
  2. cutnow-0.1.0/PKG-INFO +224 -0
  3. cutnow-0.1.0/README.md +203 -0
  4. cutnow-0.1.0/core/__init__.py +0 -0
  5. cutnow-0.1.0/core/action.py +89 -0
  6. cutnow-0.1.0/core/analyze.py +109 -0
  7. cutnow-0.1.0/core/builtin_ops.py +508 -0
  8. cutnow-0.1.0/core/clip.py +98 -0
  9. cutnow-0.1.0/core/config.py +106 -0
  10. cutnow-0.1.0/core/edl.py +42 -0
  11. cutnow-0.1.0/core/ffmpeg.py +34 -0
  12. cutnow-0.1.0/core/frames.py +19 -0
  13. cutnow-0.1.0/core/ops.py +32 -0
  14. cutnow-0.1.0/core/plan.py +177 -0
  15. cutnow-0.1.0/core/qc.py +92 -0
  16. cutnow-0.1.0/core/render.py +113 -0
  17. cutnow-0.1.0/core/runner.py +62 -0
  18. cutnow-0.1.0/core/solution.py +88 -0
  19. cutnow-0.1.0/cutnow.egg-info/PKG-INFO +224 -0
  20. cutnow-0.1.0/cutnow.egg-info/SOURCES.txt +36 -0
  21. cutnow-0.1.0/cutnow.egg-info/dependency_links.txt +1 -0
  22. cutnow-0.1.0/cutnow.egg-info/entry_points.txt +2 -0
  23. cutnow-0.1.0/cutnow.egg-info/requires.txt +12 -0
  24. cutnow-0.1.0/cutnow.egg-info/top_level.txt +4 -0
  25. cutnow-0.1.0/cutnow.py +374 -0
  26. cutnow-0.1.0/pyproject.toml +30 -0
  27. cutnow-0.1.0/setup.cfg +4 -0
  28. cutnow-0.1.0/solutions/__init__.py +0 -0
  29. cutnow-0.1.0/solutions/film-montage.solution.json +130 -0
  30. cutnow-0.1.0/solutions/mj-remake.solution.json +121 -0
  31. cutnow-0.1.0/solutions/sports-highlight.solution.json +101 -0
  32. cutnow-0.1.0/solutions/talk-highlight.solution.json +81 -0
  33. cutnow-0.1.0/tests/test_smoke.py +55 -0
  34. cutnow-0.1.0/workbench/__init__.py +0 -0
  35. cutnow-0.1.0/workbench/server.py +260 -0
  36. cutnow-0.1.0/workbench/static/app.js +158 -0
  37. cutnow-0.1.0/workbench/static/index.html +38 -0
  38. cutnow-0.1.0/workbench/static/style.css +56 -0
cutnow-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vcut contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
cutnow-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: cutnow
3
+ Version: 0.1.0
4
+ Summary: Deterministic, inspectable video editing for agents — solutions, ops and EDL.
5
+ Author: vcut contributors
6
+ License: MIT
7
+ Keywords: video,editing,ffmpeg,edl,agent,clip
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy>=1.22
12
+ Requires-Dist: imageio-ffmpeg>=0.4
13
+ Provides-Extra: clip
14
+ Requires-Dist: onnxruntime>=1.16; extra == "clip"
15
+ Requires-Dist: tokenizers>=0.15; extra == "clip"
16
+ Provides-Extra: qc
17
+ Requires-Dist: opencv-python-headless>=4.8; extra == "qc"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ <div align="center">
23
+
24
+ # cutnow
25
+
26
+ **A deterministic editing engine that agents can drive and humans can audit.**
27
+
28
+ [![ci](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml/badge.svg)](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml)
29
+ [![pypi](https://img.shields.io/pypi/v/cutnow.svg)](https://pypi.org/project/cutnow/)
30
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://pypi.org/project/cutnow/)
31
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
32
+
33
+ [中文](README.zh-CN.md) · [Docs](docs/) · [Examples](solutions/)
34
+
35
+ </div>
36
+
37
+ ![cutnow demo](docs/images/demo.gif)
38
+
39
+ ```bash
40
+ pip install cutnow
41
+ cutnow doctor
42
+ cutnow run sports --source match.mp4 --out highlight.mp4
43
+ ```
44
+
45
+ That is a 10-second vertical-ready highlight cut on the beat, with title and end cards baked in —
46
+ no timeline app, no render queue, no manual keyframes.
47
+
48
+ ---
49
+
50
+ ## Why not "one-click AI video"?
51
+
52
+ Most tools give you a **finished file**. You cannot change one cut without starting over, and you
53
+ cannot ask *why* that cut is there.
54
+
55
+ cutnow gives you an **edit decision list (EDL)** — a plain JSON timeline you can read, diff and
56
+ rewrite — plus the engine that turns it into a video, byte-for-byte reproducibly.
57
+
58
+ | Typical tool | cutnow |
59
+ |---|---|
60
+ | "AI edited your video" | every cut has a score and a reason |
61
+ | change = re-generate everything | change one line, re-render in seconds |
62
+ | one workflow per product | a new recipe is a JSON file, engine untouched |
63
+ | output only | output + EDL + QC report |
64
+
65
+ ## Concepts
66
+
67
+ ```
68
+ Solution (solution.json) what a recipe does: which ops, in what order, where a human must confirm
69
+ ↓
70
+ Op (core/builtin_ops) one deterministic job: cut, beat-align, score, subtitle, render, QC
71
+ ↓
72
+ EDL (out/edl_*.json) the decision list: source in-point, duration, position, audio, subtitles
73
+ ```
74
+
75
+ Three ideas, and that's the whole system: **solutions are data, ops are code, the EDL is the contract.**
76
+
77
+ ## Built-in solutions
78
+
79
+ | Solution | Input | Output | Needs |
80
+ |---|---|---|---|
81
+ | `film-montage` | one long video + music | ~20s beat-cut montage with title/end cards | — |
82
+ | `sports-highlight` | match footage | ~12s highlight, cuts land on shot boundaries | CLIP *(optional, degrades gracefully)* |
83
+ | `talk-highlight` | talking-head video | ~15s vertical clip with aligned subtitles | whisper.cpp *(optional)* |
84
+ | `mj-remake` | source video + track | beat-cut re-edit | — |
85
+
86
+ ```bash
87
+ cutnow list # solutions + ops
88
+ cutnow run film --source movie.mov --out montage.mp4
89
+ cutnow run talk --source talk.mp4 --out clip.mp4
90
+ ```
91
+
92
+ ## How it decides (and shows you)
93
+
94
+ A real run, 9 stock clips, fully unattended, **0 tokens**:
95
+
96
+ ```
97
+ 9 sources → 4 rejected by the pre-check → 5 rendered → 5/5 pass QC → 86s total
98
+ ```
99
+
100
+ The pre-check is not a vibe: it samples frames and asks a CLIP model whether this looks like a
101
+ match at all.
102
+
103
+ ```
104
+ sport_prob 0.994 → keep (beach volleyball)
105
+ sport_prob 0.093 → reject (people walking down a street)
106
+ ```
107
+
108
+ Then selection scores every candidate window on **semantic match + motion + audio energy**, keeps
109
+ cuts on shot boundaries, and renders. Finally QC blocks anything that would ship broken:
110
+
111
+ ```
112
+ duration 10.40s ✓ black frames 0 ✓ −12.6 LUFS ✓ subtitle fit 12/16 ✓ sharpness 314 ✓
113
+ ```
114
+
115
+ ![timeline](docs/images/timeline.png)
116
+
117
+ *The optional local workbench (`cutnow serve`): shots, beat grid, versions, and the same EDL the CLI writes.*
118
+
119
+ ## Write your own solution
120
+
121
+ ```bash
122
+ cutnow new my-recipe # scaffolds solutions/my-recipe.solution.json
123
+ cutnow solution validate solutions/my-recipe.solution.json
124
+ cutnow run my-recipe --source in.mp4 --out out.mp4
125
+ ```
126
+
127
+ ```json
128
+ {
129
+ "id": "my-recipe",
130
+ "inputs": { "source": { "type": "video", "required": true } },
131
+ "steps": [
132
+ { "op": "analyze.standard", "params": { "features": "auto" } },
133
+ { "op": "select.action", "gate": "confirm", "params": { "target_seconds": 12 } },
134
+ { "op": "card.bookends", "params": { "title": "MY FILM", "end": "cutnow" } },
135
+ { "op": "render.edl", "params": { "out": "out/my-recipe.mp4" } },
136
+ { "op": "qc.report", "params": {} }
137
+ ]
138
+ }
139
+ ```
140
+
141
+ Unknown ops and invalid gates are rejected at **validation** time — a solution cannot execute code
142
+ you did not review. `"gate": "confirm"` stops for a human before a costly or irreversible step.
143
+
144
+ ## Ops
145
+
146
+ | Group | Ops |
147
+ |---|---|
148
+ | Analyze | `analyze.standard` `analyze.media` |
149
+ | Select | `select.shots` `select.auto` `select.action` `select.semantic` |
150
+ | Speech | `transcribe.whisper` `cut.by_sentence` |
151
+ | Audio | `audio.chorus` `audio.from_clips` `audio.normalize` |
152
+ | Text & framing | `subtitle.by_beat` `card.bookends` `frame.vertical` `cleanup.crop` |
153
+ | Output | `render.edl` `qc.report` `qc.source` |
154
+
155
+ ## Install
156
+
157
+ ```bash
158
+ pip install cutnow # or: pipx install cutnow
159
+ cutnow doctor # ffmpeg / models / fonts, with what to do about each
160
+ ```
161
+
162
+ ffmpeg ships with the package via `imageio-ffmpeg`, so a fresh install already renders.
163
+ Optional extras:
164
+
165
+ ```bash
166
+ pip install "cutnow[clip]" # semantic selection (~600MB of ONNX models)
167
+ pip install "cutnow[qc]" # sharpness metric via OpenCV
168
+ python scripts/fetch_models.py --clip --whisper small
169
+ ```
170
+
171
+ Everything optional degrades: without CLIP, selection falls back to motion + audio rules; without
172
+ whisper, the talk solution reports what is missing instead of producing a broken file.
173
+
174
+ ## CLI
175
+
176
+ ```
177
+ cutnow list # solutions and ops
178
+ cutnow run <solution> # --source, --music, --out, --yes, --json
179
+ cutnow new <name> # scaffold a solution
180
+ cutnow solution validate <path>
181
+ cutnow serve [--port 8799] # local timeline workbench
182
+ cutnow doctor
183
+ ```
184
+
185
+ ## Repository layout
186
+
187
+ ```
188
+ cutnow.py CLI
189
+ core/ engine: ops, solutions, EDL, render, QC, config
190
+ solutions/ built-in recipes (data only)
191
+ recipes/ legacy single-pass recipe format
192
+ workbench/ optional local HTTP timeline (stdlib only)
193
+ contrib/ optional integrations (Harness plugin, …)
194
+ docs/ images and notes
195
+ ```
196
+
197
+ ## Limitations
198
+
199
+ Honest list, so nobody is surprised:
200
+
201
+ - **No transitions, grading, or multi-track.** Cuts are hard cuts; that is the current engine.
202
+ - **Face swap and face-identity modules are not included** (compliance). Interfaces exist; the
203
+ implementations are intentionally absent.
204
+ - **Deterministic, not tasteful.** QC guarantees the file is shippable, not that the edit is good.
205
+ - **No cloud APIs.** No keys, no per-call cost, no rate limits — and no generative model either.
206
+
207
+ ## Roadmap
208
+
209
+ - [x] EDL + deterministic render + QC gate
210
+ - [x] Solution manifests with validation and human gates
211
+ - [x] Semantic source pre-check and shot selection (CLIP)
212
+ - [ ] Proposal layer: `plan → diff → apply` instead of direct edits
213
+ - [ ] Cross-fades and simple grading
214
+ - [ ] Batch runner with a single report (in `contrib/` today)
215
+
216
+ ## Credits
217
+
218
+ Demo footage: *Tears of Steel* © Blender Foundation, [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
219
+ Built on ffmpeg, [CLIP](https://github.com/openai/CLIP) (via ONNX), and
220
+ [whisper.cpp](https://github.com/ggerganov/whisper.cpp).
221
+
222
+ ## License
223
+
224
+ MIT — see [LICENSE](LICENSE).
cutnow-0.1.0/README.md ADDED
@@ -0,0 +1,203 @@
1
+ <div align="center">
2
+
3
+ # cutnow
4
+
5
+ **A deterministic editing engine that agents can drive and humans can audit.**
6
+
7
+ [![ci](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml/badge.svg)](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml)
8
+ [![pypi](https://img.shields.io/pypi/v/cutnow.svg)](https://pypi.org/project/cutnow/)
9
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://pypi.org/project/cutnow/)
10
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
11
+
12
+ [中文](README.zh-CN.md) · [Docs](docs/) · [Examples](solutions/)
13
+
14
+ </div>
15
+
16
+ ![cutnow demo](docs/images/demo.gif)
17
+
18
+ ```bash
19
+ pip install cutnow
20
+ cutnow doctor
21
+ cutnow run sports --source match.mp4 --out highlight.mp4
22
+ ```
23
+
24
+ That is a 10-second vertical-ready highlight cut on the beat, with title and end cards baked in —
25
+ no timeline app, no render queue, no manual keyframes.
26
+
27
+ ---
28
+
29
+ ## Why not "one-click AI video"?
30
+
31
+ Most tools give you a **finished file**. You cannot change one cut without starting over, and you
32
+ cannot ask *why* that cut is there.
33
+
34
+ cutnow gives you an **edit decision list (EDL)** — a plain JSON timeline you can read, diff and
35
+ rewrite — plus the engine that turns it into a video, byte-for-byte reproducibly.
36
+
37
+ | Typical tool | cutnow |
38
+ |---|---|
39
+ | "AI edited your video" | every cut has a score and a reason |
40
+ | change = re-generate everything | change one line, re-render in seconds |
41
+ | one workflow per product | a new recipe is a JSON file, engine untouched |
42
+ | output only | output + EDL + QC report |
43
+
44
+ ## Concepts
45
+
46
+ ```
47
+ Solution (solution.json) what a recipe does: which ops, in what order, where a human must confirm
48
+ ↓
49
+ Op (core/builtin_ops) one deterministic job: cut, beat-align, score, subtitle, render, QC
50
+ ↓
51
+ EDL (out/edl_*.json) the decision list: source in-point, duration, position, audio, subtitles
52
+ ```
53
+
54
+ Three ideas, and that's the whole system: **solutions are data, ops are code, the EDL is the contract.**
55
+
56
+ ## Built-in solutions
57
+
58
+ | Solution | Input | Output | Needs |
59
+ |---|---|---|---|
60
+ | `film-montage` | one long video + music | ~20s beat-cut montage with title/end cards | — |
61
+ | `sports-highlight` | match footage | ~12s highlight, cuts land on shot boundaries | CLIP *(optional, degrades gracefully)* |
62
+ | `talk-highlight` | talking-head video | ~15s vertical clip with aligned subtitles | whisper.cpp *(optional)* |
63
+ | `mj-remake` | source video + track | beat-cut re-edit | — |
64
+
65
+ ```bash
66
+ cutnow list # solutions + ops
67
+ cutnow run film --source movie.mov --out montage.mp4
68
+ cutnow run talk --source talk.mp4 --out clip.mp4
69
+ ```
70
+
71
+ ## How it decides (and shows you)
72
+
73
+ A real run, 9 stock clips, fully unattended, **0 tokens**:
74
+
75
+ ```
76
+ 9 sources → 4 rejected by the pre-check → 5 rendered → 5/5 pass QC → 86s total
77
+ ```
78
+
79
+ The pre-check is not a vibe: it samples frames and asks a CLIP model whether this looks like a
80
+ match at all.
81
+
82
+ ```
83
+ sport_prob 0.994 → keep (beach volleyball)
84
+ sport_prob 0.093 → reject (people walking down a street)
85
+ ```
86
+
87
+ Then selection scores every candidate window on **semantic match + motion + audio energy**, keeps
88
+ cuts on shot boundaries, and renders. Finally QC blocks anything that would ship broken:
89
+
90
+ ```
91
+ duration 10.40s ✓ black frames 0 ✓ −12.6 LUFS ✓ subtitle fit 12/16 ✓ sharpness 314 ✓
92
+ ```
93
+
94
+ ![timeline](docs/images/timeline.png)
95
+
96
+ *The optional local workbench (`cutnow serve`): shots, beat grid, versions, and the same EDL the CLI writes.*
97
+
98
+ ## Write your own solution
99
+
100
+ ```bash
101
+ cutnow new my-recipe # scaffolds solutions/my-recipe.solution.json
102
+ cutnow solution validate solutions/my-recipe.solution.json
103
+ cutnow run my-recipe --source in.mp4 --out out.mp4
104
+ ```
105
+
106
+ ```json
107
+ {
108
+ "id": "my-recipe",
109
+ "inputs": { "source": { "type": "video", "required": true } },
110
+ "steps": [
111
+ { "op": "analyze.standard", "params": { "features": "auto" } },
112
+ { "op": "select.action", "gate": "confirm", "params": { "target_seconds": 12 } },
113
+ { "op": "card.bookends", "params": { "title": "MY FILM", "end": "cutnow" } },
114
+ { "op": "render.edl", "params": { "out": "out/my-recipe.mp4" } },
115
+ { "op": "qc.report", "params": {} }
116
+ ]
117
+ }
118
+ ```
119
+
120
+ Unknown ops and invalid gates are rejected at **validation** time — a solution cannot execute code
121
+ you did not review. `"gate": "confirm"` stops for a human before a costly or irreversible step.
122
+
123
+ ## Ops
124
+
125
+ | Group | Ops |
126
+ |---|---|
127
+ | Analyze | `analyze.standard` `analyze.media` |
128
+ | Select | `select.shots` `select.auto` `select.action` `select.semantic` |
129
+ | Speech | `transcribe.whisper` `cut.by_sentence` |
130
+ | Audio | `audio.chorus` `audio.from_clips` `audio.normalize` |
131
+ | Text & framing | `subtitle.by_beat` `card.bookends` `frame.vertical` `cleanup.crop` |
132
+ | Output | `render.edl` `qc.report` `qc.source` |
133
+
134
+ ## Install
135
+
136
+ ```bash
137
+ pip install cutnow # or: pipx install cutnow
138
+ cutnow doctor # ffmpeg / models / fonts, with what to do about each
139
+ ```
140
+
141
+ ffmpeg ships with the package via `imageio-ffmpeg`, so a fresh install already renders.
142
+ Optional extras:
143
+
144
+ ```bash
145
+ pip install "cutnow[clip]" # semantic selection (~600MB of ONNX models)
146
+ pip install "cutnow[qc]" # sharpness metric via OpenCV
147
+ python scripts/fetch_models.py --clip --whisper small
148
+ ```
149
+
150
+ Everything optional degrades: without CLIP, selection falls back to motion + audio rules; without
151
+ whisper, the talk solution reports what is missing instead of producing a broken file.
152
+
153
+ ## CLI
154
+
155
+ ```
156
+ cutnow list # solutions and ops
157
+ cutnow run <solution> # --source, --music, --out, --yes, --json
158
+ cutnow new <name> # scaffold a solution
159
+ cutnow solution validate <path>
160
+ cutnow serve [--port 8799] # local timeline workbench
161
+ cutnow doctor
162
+ ```
163
+
164
+ ## Repository layout
165
+
166
+ ```
167
+ cutnow.py CLI
168
+ core/ engine: ops, solutions, EDL, render, QC, config
169
+ solutions/ built-in recipes (data only)
170
+ recipes/ legacy single-pass recipe format
171
+ workbench/ optional local HTTP timeline (stdlib only)
172
+ contrib/ optional integrations (Harness plugin, …)
173
+ docs/ images and notes
174
+ ```
175
+
176
+ ## Limitations
177
+
178
+ Honest list, so nobody is surprised:
179
+
180
+ - **No transitions, grading, or multi-track.** Cuts are hard cuts; that is the current engine.
181
+ - **Face swap and face-identity modules are not included** (compliance). Interfaces exist; the
182
+ implementations are intentionally absent.
183
+ - **Deterministic, not tasteful.** QC guarantees the file is shippable, not that the edit is good.
184
+ - **No cloud APIs.** No keys, no per-call cost, no rate limits — and no generative model either.
185
+
186
+ ## Roadmap
187
+
188
+ - [x] EDL + deterministic render + QC gate
189
+ - [x] Solution manifests with validation and human gates
190
+ - [x] Semantic source pre-check and shot selection (CLIP)
191
+ - [ ] Proposal layer: `plan → diff → apply` instead of direct edits
192
+ - [ ] Cross-fades and simple grading
193
+ - [ ] Batch runner with a single report (in `contrib/` today)
194
+
195
+ ## Credits
196
+
197
+ Demo footage: *Tears of Steel* © Blender Foundation, [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
198
+ Built on ffmpeg, [CLIP](https://github.com/openai/CLIP) (via ONNX), and
199
+ [whisper.cpp](https://github.com/ggerganov/whisper.cpp).
200
+
201
+ ## License
202
+
203
+ MIT — see [LICENSE](LICENSE).
File without changes
@@ -0,0 +1,89 @@
1
+ """运动素材的选段信号:音频能量 + 画面运动 + 主体占比 + 场景对齐。"""
2
+ import numpy as np
3
+ from . import analyze as A
4
+
5
+
6
+ def audio_env(path, hop=0.1, sr=22050):
7
+ """整段音频的短时能量包络(0-1 归一化)。"""
8
+ x = A.decode_audio(path, sr)
9
+ if x is None or len(x) == 0:
10
+ return None, sr
11
+ n = int(hop * sr)
12
+ frames = len(x) // n
13
+ if frames < 2:
14
+ return None, sr
15
+ e = np.array([float(np.sqrt(np.mean(x[i * n:(i + 1) * n] ** 2))) for i in range(frames)])
16
+ hi = np.percentile(e, 95) or 1.0
17
+ return np.clip(e / hi, 0, 1), hop
18
+
19
+
20
+ def env_stats(env, hop, s, e):
21
+ if env is None:
22
+ return 0.0, 0.0
23
+ a, b = int(s / hop), int(e / hop)
24
+ seg = env[max(0, a):max(1, b)]
25
+ if len(seg) == 0:
26
+ return 0.0, 0.0
27
+ return float(np.mean(seg)), float(np.percentile(seg, 90))
28
+
29
+
30
+ def scene_candidates(scenes, rows, env, hop, beat, beats=(4, 8, 12), pad=0.15):
31
+ """在场景内部平铺开窗:切点对齐镜头边界与前一个窗尾,长镜头也能被多次使用。"""
32
+ out = []
33
+ for s, e in scenes:
34
+ span = e - s
35
+ if span < 1.2:
36
+ continue
37
+ for b in beats:
38
+ L = b * beat
39
+ if L > span + 0.45:
40
+ continue
41
+ k = 0
42
+ while True:
43
+ st = s + pad + k * L
44
+ if st + L > e + 0.45:
45
+ break
46
+ k += 1
47
+ rows_in = [r for r in rows if st <= r["t"] < st + L]
48
+ if len(rows_in) < 2:
49
+ continue
50
+ n = len(rows_in)
51
+ avg = lambda key: sum(r.get(key, 0) for r in rows_in) / n
52
+ a_mean, a_p90 = env_stats(env, hop, st, st + L)
53
+ out.append({"start": round(st, 2), "beats": b, "dur": round(L, 3),
54
+ "scene": (s, e),
55
+ "motion": avg("motion"),
56
+ "motion_min": min(r.get("motion", 0) for r in rows_in),
57
+ "human": avg("human_area"), "luma": avg("luma"), "edge": avg("edge"),
58
+ "audio": a_mean, "audio_peak": a_p90})
59
+ return out
60
+
61
+
62
+ def score_action(c, clip_p75_motion=20.0, clip_p60_audio=0.3):
63
+ motion = min(c["motion"] / max(clip_p75_motion * 1.6, 1e-6), 1.0)
64
+ sustain = min(c["motion_min"] / max(clip_p75_motion * 0.6, 1e-6), 1.0)
65
+ audio = min(c["audio_peak"] / max(clip_p60_audio * 1.8, 1e-6), 1.0)
66
+ human = min(c["human"] / 0.10, 1.0)
67
+ dark = 1.0 if c["luma"] >= 35 else max(0.4, c["luma"] / 35.0)
68
+ base = 0.34 * motion + 0.16 * sustain + 0.30 * audio + 0.12 * human + 0.08 * min(c["edge"] / 14.0, 1.0)
69
+ return round(base * dark, 4)
70
+
71
+
72
+ def pick_action(cands, target, beat, gap_ratio=1.15):
73
+ """贪心:分数最高优先,窗口之间至少隔 1.4 个窗长,避免同一段反复用。"""
74
+ chosen, used = [], 0.0
75
+ target_beats = max(2, int(round(target / beat)))
76
+ for c in sorted(cands, key=lambda x: -x.get("score", x.get("action", 0.0))):
77
+ if used >= target_beats:
78
+ break
79
+ if any(abs(c["start"] - o["start"]) < max(c["dur"], o["dur"]) * gap_ratio for o in chosen):
80
+ continue
81
+ same = [o for o in chosen if o["scene"] == c["scene"]]
82
+ span = c["scene"][1] - c["scene"][0]
83
+ cap = max(2, int(span / max(c["dur"], 0.5)))
84
+ if len(same) >= cap:
85
+ continue
86
+ chosen.append(c)
87
+ used += c["beats"]
88
+ chosen.sort(key=lambda c: c["start"])
89
+ return chosen, used
@@ -0,0 +1,109 @@
1
+ """素材分析:镜头切分、音频解码、节拍估计、逐帧特征聚合。"""
2
+ import json, re, subprocess
3
+ import numpy as np
4
+ from . import ffmpeg as F
5
+
6
+
7
+ # ---------- 音频 ----------
8
+
9
+ def decode_audio(path: str, sr: int = 22050) -> np.ndarray:
10
+ p = subprocess.run([F.ffmpeg(), "-hide_banner", "-nostdin", "-i", path,
11
+ "-f", "f32le", "-ac", "1", "-ar", str(sr), "-"],
12
+ capture_output=True)
13
+ if p.returncode != 0:
14
+ raise RuntimeError(p.stderr.decode("utf-8", "ignore")[-800:])
15
+ return np.frombuffer(p.stdout, dtype=np.float32)
16
+
17
+
18
+ def _stft_mag(x, n_fft=1024, hop=512):
19
+ if len(x) < n_fft:
20
+ x = np.pad(x, (0, n_fft - len(x)))
21
+ n = 1 + (len(x) - n_fft) // hop
22
+ idx = np.arange(n_fft)[None, :] + hop * np.arange(n)[:, None]
23
+ frames = x[idx] * np.hanning(n_fft)[None, :]
24
+ return np.abs(np.fft.rfft(frames, axis=1))
25
+
26
+
27
+ def onset_envelope(x, n_fft=1024, hop=512):
28
+ S = _stft_mag(x, n_fft, hop)
29
+ d = np.diff(S, axis=0, prepend=S[:1])
30
+ flux = np.maximum(d, 0).sum(axis=1)
31
+ flux = flux - flux.mean()
32
+ flux = np.maximum(flux, 0)
33
+ return flux / (flux.max() + 1e-9), hop
34
+
35
+
36
+ def estimate_beat(x, sr=22050, bpm_range=(70, 190)):
37
+ """自相关估 tempo + 梳状滤波定相位,返回 (bpm, beat_times_sec)。"""
38
+ env, hop = onset_envelope(x)
39
+ fps = sr / hop
40
+ e = env - env.mean()
41
+ ac = np.correlate(e, e, "full")[len(e) - 1:]
42
+ lo = int(fps * 60 / bpm_range[1]); hi = int(fps * 60 / bpm_range[0])
43
+ lag = lo + int(np.argmax(ac[lo:hi]))
44
+ bpm = 60 * fps / lag
45
+
46
+ # 相位:在 [0, lag) 内找使节拍点能量和最大的偏移
47
+ best, best_off = -1, 0
48
+ for off in range(lag):
49
+ idx = np.arange(off, len(env), lag).astype(int)
50
+ s = env[idx].sum()
51
+ if s > best:
52
+ best, best_off = s, off
53
+ beats = np.arange(best_off, len(env), lag) / fps
54
+ return round(bpm, 2), beats
55
+
56
+
57
+ def loudest_window(x, sr, dur):
58
+ """能量最大的 dur 秒窗口起点(用于自动取副歌)。"""
59
+ win = int(sr * dur)
60
+ if len(x) <= win:
61
+ return 0.0
62
+ step = int(sr * 0.5)
63
+ frames = 1 + (len(x) - win) // step
64
+ idx = np.arange(win)[None, :] + step * np.arange(frames)[:, None]
65
+ energy = (x[idx] ** 2).mean(axis=1)
66
+ # 平滑,避免选到单次爆炸音
67
+ k = max(1, int(2 / 0.5))
68
+ sm = np.convolve(energy, np.ones(k) / k, mode="same")
69
+ return float(np.argmax(sm) * step / sr)
70
+
71
+
72
+ # ---------- 视频 ----------
73
+
74
+ def scenes(path: str, threshold: float = 0.3):
75
+ """ffmpeg scene 检测,返回 [(start, end)]。"""
76
+ p = F.run(["-i", path, "-filter_complex", f"select='gt(scene,{threshold})',showinfo",
77
+ "-f", "null", "-"])
78
+ cuts = [float(m) for m in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
79
+ dur = F.probe(path)["duration"]
80
+ pts = [0.0] + [c for c in cuts if 0.3 < c < dur - 0.3]
81
+ out = []
82
+ for i, s in enumerate(pts):
83
+ e = pts[i + 1] if i + 1 < len(pts) else dur
84
+ if e - s >= 0.35:
85
+ out.append((round(s, 3), round(e, 3)))
86
+ return out
87
+
88
+
89
+ def load_features(path: str):
90
+ rows = []
91
+ with open(path) as fh:
92
+ for line in fh:
93
+ line = line.strip()
94
+ if line:
95
+ rows.append(json.loads(line))
96
+ return rows
97
+
98
+
99
+ def aggregate(rows, start, end):
100
+ sel = [r for r in rows if start <= r["t"] < end]
101
+ if not sel:
102
+ return {"n": 0, "luma": 0, "motion": 0, "face_area": 0, "faces": 0}
103
+ return {
104
+ "n": len(sel),
105
+ "luma": float(np.mean([r["luma"] for r in sel])),
106
+ "motion": float(np.mean([r["motion"] for r in sel])),
107
+ "face_area": float(np.max([r["face_area"] for r in sel])),
108
+ "faces": float(np.mean([r["faces"] for r in sel])),
109
+ }