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.
- cutnow-0.1.0/LICENSE +21 -0
- cutnow-0.1.0/PKG-INFO +224 -0
- cutnow-0.1.0/README.md +203 -0
- cutnow-0.1.0/core/__init__.py +0 -0
- cutnow-0.1.0/core/action.py +89 -0
- cutnow-0.1.0/core/analyze.py +109 -0
- cutnow-0.1.0/core/builtin_ops.py +508 -0
- cutnow-0.1.0/core/clip.py +98 -0
- cutnow-0.1.0/core/config.py +106 -0
- cutnow-0.1.0/core/edl.py +42 -0
- cutnow-0.1.0/core/ffmpeg.py +34 -0
- cutnow-0.1.0/core/frames.py +19 -0
- cutnow-0.1.0/core/ops.py +32 -0
- cutnow-0.1.0/core/plan.py +177 -0
- cutnow-0.1.0/core/qc.py +92 -0
- cutnow-0.1.0/core/render.py +113 -0
- cutnow-0.1.0/core/runner.py +62 -0
- cutnow-0.1.0/core/solution.py +88 -0
- cutnow-0.1.0/cutnow.egg-info/PKG-INFO +224 -0
- cutnow-0.1.0/cutnow.egg-info/SOURCES.txt +36 -0
- cutnow-0.1.0/cutnow.egg-info/dependency_links.txt +1 -0
- cutnow-0.1.0/cutnow.egg-info/entry_points.txt +2 -0
- cutnow-0.1.0/cutnow.egg-info/requires.txt +12 -0
- cutnow-0.1.0/cutnow.egg-info/top_level.txt +4 -0
- cutnow-0.1.0/cutnow.py +374 -0
- cutnow-0.1.0/pyproject.toml +30 -0
- cutnow-0.1.0/setup.cfg +4 -0
- cutnow-0.1.0/solutions/__init__.py +0 -0
- cutnow-0.1.0/solutions/film-montage.solution.json +130 -0
- cutnow-0.1.0/solutions/mj-remake.solution.json +121 -0
- cutnow-0.1.0/solutions/sports-highlight.solution.json +101 -0
- cutnow-0.1.0/solutions/talk-highlight.solution.json +81 -0
- cutnow-0.1.0/tests/test_smoke.py +55 -0
- cutnow-0.1.0/workbench/__init__.py +0 -0
- cutnow-0.1.0/workbench/server.py +260 -0
- cutnow-0.1.0/workbench/static/app.js +158 -0
- cutnow-0.1.0/workbench/static/index.html +38 -0
- 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
|
+
[](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/cutnow/)
|
|
30
|
+
[](https://pypi.org/project/cutnow/)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
|
|
33
|
+
[中文](README.zh-CN.md) · [Docs](docs/) · [Examples](solutions/)
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+

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

|
|
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
|
+
[](https://github.com/jchen0102/cutnow/actions/workflows/ci.yml)
|
|
8
|
+
[](https://pypi.org/project/cutnow/)
|
|
9
|
+
[](https://pypi.org/project/cutnow/)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
[中文](README.zh-CN.md) · [Docs](docs/) · [Examples](solutions/)
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+

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

|
|
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
|
+
}
|