proofcut 0.29.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.
Files changed (88) hide show
  1. proofcut/__init__.py +14 -0
  2. proofcut/_face_worker.py +109 -0
  3. proofcut/_tts_worker.py +108 -0
  4. proofcut/_vlm_worker.py +170 -0
  5. proofcut/asr.py +711 -0
  6. proofcut/autoeditor.py +309 -0
  7. proofcut/briefs.py +214 -0
  8. proofcut/captions.py +908 -0
  9. proofcut/cli.py +3345 -0
  10. proofcut/deps.py +36 -0
  11. proofcut/describe.py +308 -0
  12. proofcut/doctor.py +1059 -0
  13. proofcut/duck.py +149 -0
  14. proofcut/energy.py +499 -0
  15. proofcut/faces.py +352 -0
  16. proofcut/finish.py +362 -0
  17. proofcut/finishlog.py +118 -0
  18. proofcut/fonts/FONTS.md +96 -0
  19. proofcut/fonts/OFL-Outfit.txt +93 -0
  20. proofcut/fonts/Outfit[wght].ttf +0 -0
  21. proofcut/fonts/static/Outfit-Bold.ttf +0 -0
  22. proofcut/fonts/static/Outfit-Regular.ttf +0 -0
  23. proofcut/fonts.py +601 -0
  24. proofcut/graphics.py +2215 -0
  25. proofcut/install.py +663 -0
  26. proofcut/media.py +1298 -0
  27. proofcut/mlt.py +2025 -0
  28. proofcut/ops.py +16721 -0
  29. proofcut/pack.py +327 -0
  30. proofcut/picture.py +1156 -0
  31. proofcut/progress.py +254 -0
  32. proofcut/project.py +967 -0
  33. proofcut/py.typed +0 -0
  34. proofcut/renderlog.py +149 -0
  35. proofcut/reviewserver.py +487 -0
  36. proofcut/server.py +5194 -0
  37. proofcut/speakers.py +279 -0
  38. proofcut/speech.py +87 -0
  39. proofcut/templates/bumper.portrait.svg +10 -0
  40. proofcut/templates/bumper.svg +10 -0
  41. proofcut/templates/chapter.portrait.svg +11 -0
  42. proofcut/templates/chapter.svg +11 -0
  43. proofcut/templates/endcard.portrait.svg +5 -0
  44. proofcut/templates/endcard.svg +5 -0
  45. proofcut/templates/receipt.portrait.svg +8 -0
  46. proofcut/templates/receipt.svg +8 -0
  47. proofcut/templates/rerate.portrait.svg +7 -0
  48. proofcut/templates/rerate.svg +7 -0
  49. proofcut/templates/reveal.portrait.svg +7 -0
  50. proofcut/templates/reveal.svg +7 -0
  51. proofcut/timeline.py +797 -0
  52. proofcut/transcript.py +584 -0
  53. proofcut/tts.py +279 -0
  54. proofcut/verify.py +235 -0
  55. proofcut/web/FONTS.md +26 -0
  56. proofcut/web/LICENSE-geist-sans.txt +92 -0
  57. proofcut/web/LICENSE-jetbrains-mono.txt +93 -0
  58. proofcut/web/LICENSE-source-serif-4.txt +93 -0
  59. proofcut/web/agent.js +989 -0
  60. proofcut/web/api.js +110 -0
  61. proofcut/web/app.css +4217 -0
  62. proofcut/web/app.js +749 -0
  63. proofcut/web/assets.js +745 -0
  64. proofcut/web/dom.js +107 -0
  65. proofcut/web/favicon.svg +10 -0
  66. proofcut/web/finish.js +493 -0
  67. proofcut/web/frame.js +951 -0
  68. proofcut/web/geist-sans-400.woff2 +0 -0
  69. proofcut/web/geist-sans-500.woff2 +0 -0
  70. proofcut/web/geist-sans-600.woff2 +0 -0
  71. proofcut/web/geist-sans-700.woff2 +0 -0
  72. proofcut/web/index.html +629 -0
  73. proofcut/web/jetbrains-mono.woff2 +0 -0
  74. proofcut/web/picker.html +45 -0
  75. proofcut/web/picker.js +570 -0
  76. proofcut/web/player.js +1152 -0
  77. proofcut/web/properties.js +271 -0
  78. proofcut/web/source-serif-4-italic.woff2 +0 -0
  79. proofcut/web/source-serif-4.woff2 +0 -0
  80. proofcut/web/theme.js +94 -0
  81. proofcut/web/timeline.js +2531 -0
  82. proofcut/web/transcript.js +1082 -0
  83. proofcut/webui.py +4022 -0
  84. proofcut-0.29.0.dist-info/METADATA +444 -0
  85. proofcut-0.29.0.dist-info/RECORD +88 -0
  86. proofcut-0.29.0.dist-info/WHEEL +4 -0
  87. proofcut-0.29.0.dist-info/entry_points.txt +3 -0
  88. proofcut-0.29.0.dist-info/licenses/LICENSE +166 -0
proofcut/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """proofcut — a source-available, local-first AI video editor.
2
+
3
+ The public surface is the MCP server (``proofcut mcp``) and the equivalent CLI
4
+ (``proofcut <subcommand>``). Everything operates on a *project directory*; see
5
+ :mod:`proofcut.project` for its layout.
6
+ """
7
+
8
+ #: Duplicated in ``pyproject.toml`` deliberately. The metadata lookup that
9
+ #: would remove the copy reads the *installed* dist-info, which goes stale
10
+ #: against an editable checkout without saying so; tests/test_version.py
11
+ #: has the reasoning and holds the two numbers together.
12
+ __version__ = "0.29.0"
13
+
14
+ __all__ = ["__version__"]
@@ -0,0 +1,109 @@
1
+ """The detector side of `faces`, run under a *different* interpreter.
2
+
3
+ **This module is never imported by proofcut.** It is executed by the Python that
4
+ `faces.face_python()` resolves — a venv with insightface and onnxruntime in it —
5
+ which is the whole reason it is a separate file. proofcut's own venv stays free of
6
+ both, exactly as `asr.py` keeps whisper behind a binary and `_vlm_worker.py`
7
+ keeps torch behind an interpreter. It lives inside the package only so it ships
8
+ with it.
9
+
10
+ It reads one JSON job from `argv[1]` and writes one JSON result to `argv[2]`. A
11
+ file rather than stdout for `_vlm_worker.py`'s reason: insightface prints its
12
+ model directory and its provider list to whichever stream it feels like, and
13
+ that landing in the middle of a JSON document is a parse error that reads like a
14
+ detector failure.
15
+
16
+ The detector is built **once** for the whole job — every window of every clip
17
+ goes through one session.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+
28
+ def _frame(media: str, ts: float):
29
+ """Pull one BGR frame from `media` at `ts`, through the ffmpeg binary.
30
+
31
+ PNG down the pipe and `cv2.imdecode` back, rather than rawvideo reshaped to
32
+ a size passed in the job: the size would be proofcut's manifest talking about
33
+ the file, and a reshape against a stale width is not an error — it is a
34
+ sheared frame that detects plausible faces in the wrong places. Here the
35
+ decoder tells us the geometry and there is nothing to disagree with.
36
+
37
+ Deliberately not `ffmpeg-python`: this file's dependencies are whatever the
38
+ resolved interpreter happens to have, and every extra import is another way
39
+ for it to fail on a box that could otherwise detect. numpy and cv2 come with
40
+ insightface; a convenience wrapper around a subprocess does not.
41
+ """
42
+ import cv2
43
+ import numpy as np
44
+
45
+ completed = subprocess.run(
46
+ [
47
+ "ffmpeg", "-nostdin", "-v", "error",
48
+ "-ss", f"{ts:.4f}", "-i", media,
49
+ "-frames:v", "1", "-f", "image2pipe", "-c:v", "png", "pipe:",
50
+ ],
51
+ capture_output=True,
52
+ check=True,
53
+ ) # fmt: skip
54
+ if not completed.stdout:
55
+ raise RuntimeError(
56
+ f"ffmpeg returned no frame at {ts:.3f}s — seeking past the end of {media}?"
57
+ )
58
+ image = cv2.imdecode(np.frombuffer(completed.stdout, np.uint8), cv2.IMREAD_COLOR)
59
+ if image is None:
60
+ raise RuntimeError(f"could not decode the frame ffmpeg returned at {ts:.3f}s")
61
+ return image
62
+
63
+
64
+ def main() -> int:
65
+ job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
66
+ destination = Path(sys.argv[2])
67
+
68
+ from insightface.app import FaceAnalysis
69
+
70
+ # `allowed_modules=["detection"]` is the whole reason this is cheap: the
71
+ # buffalo_l pack also carries recognition, landmark and gender/age models
72
+ # worth ~300 MB that a framing pass has no use for. ctx_id=-1 is CPU.
73
+ app = FaceAnalysis(
74
+ name=job["model"], allowed_modules=["detection"], providers=job["providers"]
75
+ )
76
+ app.prepare(ctx_id=-1, det_size=(job["det_size"], job["det_size"]))
77
+
78
+ results = []
79
+ for done, window in enumerate(job["windows"], 1):
80
+ try:
81
+ frames = []
82
+ for ts in window["timestamps"]:
83
+ faces = app.get(_frame(window["media"], ts))
84
+ frames.append(
85
+ {
86
+ "ts": ts,
87
+ "faces": [
88
+ {
89
+ "box": [float(v) for v in face.bbox],
90
+ "score": float(face.det_score),
91
+ }
92
+ for face in faces
93
+ ],
94
+ }
95
+ )
96
+ results.append({"index": window["index"], "frames": frames})
97
+ except Exception as exc: # noqa: BLE001 — reported per window, not fatal
98
+ results.append({"index": window["index"], "error": f"{type(exc).__name__}: {exc}"})
99
+ # `proofcut.progress.MARKER`'s line, restated: this interpreter imports
100
+ # nothing from proofcut.
101
+ sys.stderr.write(f"proofcut-progress {done} {len(job['windows'])}\n")
102
+ sys.stderr.flush()
103
+
104
+ destination.write_text(json.dumps({"results": results}), encoding="utf-8")
105
+ return 0
106
+
107
+
108
+ if __name__ == "__main__":
109
+ raise SystemExit(main())
@@ -0,0 +1,108 @@
1
+ """The synthesiser side of `tts`, run under a *different* interpreter.
2
+
3
+ **This module is never imported by proofcut.** It is executed by the Python that
4
+ `tts.tts_python()` resolves — a venv with `qwen_tts` and a CUDA torch — which is
5
+ the whole reason it is a separate file, exactly as `_vlm_worker.py` and
6
+ `_face_worker.py` keep torch and onnxruntime out of proofcut's own venv.
7
+
8
+ It reads one JSON job from `argv[1]` and writes one JSON result to `argv[2]`;
9
+ a file rather than stdout because transformers and qwen_tts both print to
10
+ whichever stream they feel like ("flash-attn is not installed…"), and that
11
+ landing in a JSON document is a parse error that reads like a synth failure.
12
+
13
+ The model is loaded **once** for the job and every seed renders through it.
14
+ The likeness number is the model's own speaker encoder — the same one it
15
+ conditions on — so `sim` is cosine(embedding(render), embedding(reference)).
16
+
17
+ `spread` is the second number, and it exists because the first one cannot see
18
+ flatness: likeness-only ranking systematically keeps the *flattest* read
19
+ (goodsometimes, 2026-08-24 — every one of the six flattest winners in a
20
+ 38-chunk essay had a livelier take among its losing seeds, one lost on a dead
21
+ sim tie). It is the std of voiced pitch in semitones around the median (pyin,
22
+ 60–400 Hz), the local-llm round-2 proxy for how much the read moves; it cannot
23
+ rank *where* emphasis lands, only whether there is any.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+
32
+
33
+ def main() -> int:
34
+ job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
35
+ destination = Path(sys.argv[2])
36
+
37
+ import librosa
38
+ import numpy as np
39
+ import soundfile as sf
40
+ import torch
41
+ from qwen_tts import Qwen3TTSModel
42
+
43
+ # `tts.device()`; absent is what every job before the key meant. Plain
44
+ # `cuda` keeps the `cuda:0` this always loaded onto.
45
+ device = job.get("device") or "cuda"
46
+ model = Qwen3TTSModel.from_pretrained(
47
+ job["model"],
48
+ device_map="cuda:0" if device == "cuda" else device,
49
+ dtype=torch.bfloat16,
50
+ attn_implementation="sdpa",
51
+ )
52
+
53
+ def pitch_spread(audio: np.ndarray, rate: int = 24000) -> float | None:
54
+ """Std of voiced f0 in semitones around its median — None under 20 voiced frames."""
55
+ f0 = librosa.pyin(audio, fmin=60, fmax=400, sr=rate)[0]
56
+ voiced = f0[~np.isnan(f0)]
57
+ if voiced.size < 20:
58
+ return None
59
+ return round(float(np.std(12 * np.log2(voiced / np.median(voiced)))), 2)
60
+
61
+ @torch.inference_mode()
62
+ def embed(audio: np.ndarray) -> np.ndarray:
63
+ e = model.model.extract_speaker_embedding(audio.astype(np.float32), 24000)
64
+ e = e.float().cpu().numpy().reshape(-1)
65
+ return e / (np.linalg.norm(e) or 1.0)
66
+
67
+ ref_audio, _ = librosa.load(job["ref_audio"], sr=24000, mono=True)
68
+ ref = embed(ref_audio)
69
+
70
+ out_dir = Path(job["out_dir"])
71
+ candidates = []
72
+ for done, seed in enumerate(job["seeds"], 1):
73
+ try:
74
+ torch.manual_seed(int(seed))
75
+ torch.cuda.manual_seed_all(int(seed))
76
+ wavs, sr = model.generate_voice_clone(
77
+ text=job["text"],
78
+ language=job["language"],
79
+ ref_audio=job["ref_audio"],
80
+ ref_text=job["ref_text"],
81
+ max_new_tokens=int(job["max_new_tokens"]),
82
+ )
83
+ wav = np.asarray(wavs[0], dtype=np.float32)
84
+ path = out_dir / f"s{int(seed)}.wav"
85
+ sf.write(str(path), wav, sr)
86
+ audio = wav if sr == 24000 else librosa.resample(wav, orig_sr=sr, target_sr=24000)
87
+ candidates.append(
88
+ {
89
+ "seed": int(seed),
90
+ "path": str(path),
91
+ "duration": round(len(wav) / sr, 3),
92
+ "sim": round(float(embed(audio) @ ref), 4),
93
+ "spread": pitch_spread(audio),
94
+ }
95
+ )
96
+ except Exception as exc: # noqa: BLE001 — reported per seed, not fatal
97
+ candidates.append({"seed": int(seed), "error": f"{type(exc).__name__}: {exc}"})
98
+ # `proofcut.progress.MARKER`'s line, restated: this interpreter imports
99
+ # nothing from proofcut.
100
+ sys.stderr.write(f"proofcut-progress {done} {len(job['seeds'])}\n")
101
+ sys.stderr.flush()
102
+
103
+ destination.write_text(json.dumps({"model": job["model"], "candidates": candidates}), encoding="utf-8")
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
@@ -0,0 +1,170 @@
1
+ """The vision-model side of `describe`, run under a *different* interpreter.
2
+
3
+ **This module is never imported by proofcut.** It is executed by the Python that
4
+ `describe.vlm_python()` resolves — a venv with torch, transformers and
5
+ bitsandbytes in it — which is the whole reason it is a separate file. proofcut's
6
+ own venv stays free of torch, exactly as `asr.py` keeps whisper behind a
7
+ binary. It lives inside the package only so it ships with it.
8
+
9
+ It reads one JSON job from `argv[1]` and writes one JSON result to `argv[2]`.
10
+ A file rather than stdout because transformers, bitsandbytes and torch all
11
+ write to whichever stream they feel like, and a progress bar landing in the
12
+ middle of a JSON document is a parse error that reads like a model failure.
13
+
14
+ The model is loaded **once** for the whole job — every window of every clip
15
+ goes through one process. Loading is ~15s and the description of a single
16
+ window is ~3s, so a process per window would spend more time loading than
17
+ describing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+
28
+ def _frames(media: str, timestamps: list[float], size: str) -> list:
29
+ """Pull one RGB frame per timestamp, through the ffmpeg binary.
30
+
31
+ Deliberately not `ffmpeg-python`: this file's dependencies are whatever
32
+ the resolved interpreter happens to have, and every extra import is
33
+ another way for it to fail on a box that could otherwise describe.
34
+ torch, transformers, numpy and PIL are unavoidable; a convenience wrapper
35
+ around a subprocess is not.
36
+ """
37
+ import numpy as np
38
+ from PIL import Image
39
+
40
+ width, height = (int(x) for x in size.split("x"))
41
+ out = []
42
+ for ts in timestamps:
43
+ completed = subprocess.run(
44
+ [
45
+ "ffmpeg", "-nostdin", "-v", "error",
46
+ "-ss", f"{ts:.3f}", "-i", media,
47
+ "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24",
48
+ "-s", size, "pipe:",
49
+ ],
50
+ capture_output=True,
51
+ check=True,
52
+ )
53
+ want = width * height * 3
54
+ if len(completed.stdout) < want:
55
+ raise RuntimeError(
56
+ f"ffmpeg returned {len(completed.stdout)} bytes for the frame at "
57
+ f"{ts:.2f}s, wanted {want} — seeking past the end of {media}?"
58
+ )
59
+ buf = np.frombuffer(completed.stdout[:want], np.uint8).reshape(height, width, 3)
60
+ out.append(Image.fromarray(buf))
61
+ return out
62
+
63
+
64
+ def _load(model_path: str, device: str) -> tuple:
65
+ """Qwen2.5-VL, 4-bit NF4 with bf16 compute, and its processor.
66
+
67
+ The config every VRAM figure in PLAN.md § B-roll by description was
68
+ measured under — until 2026-09-10 it was imported from a sibling repo's
69
+ tagger at run time, and it is carried here unchanged so that proofcut can
70
+ describe on a box that has never seen that repo.
71
+ """
72
+ import torch
73
+ from transformers import (
74
+ AutoProcessor,
75
+ BitsAndBytesConfig,
76
+ Qwen2_5_VLForConditionalGeneration,
77
+ )
78
+
79
+ quant = BitsAndBytesConfig(
80
+ load_in_4bit=True,
81
+ bnb_4bit_compute_dtype=torch.bfloat16,
82
+ bnb_4bit_use_double_quant=True,
83
+ bnb_4bit_quant_type="nf4",
84
+ )
85
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
86
+ model_path,
87
+ quantization_config=quant,
88
+ device_map=device,
89
+ torch_dtype=torch.bfloat16,
90
+ ).eval()
91
+ processor = AutoProcessor.from_pretrained(model_path)
92
+ return model, processor
93
+
94
+
95
+ def _generate(
96
+ model, processor, images: list, prompt: str, *, max_new_tokens: int, device: str
97
+ ) -> str:
98
+ """One greedy pass over the frames and the prompt; the decoded reply."""
99
+ import torch
100
+
101
+ content = [{"type": "image", "image": im} for im in images]
102
+ content.append({"type": "text", "text": prompt})
103
+ messages = [{"role": "user", "content": content}]
104
+ text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
105
+ inputs = processor(text=[text], images=list(images), return_tensors="pt").to(device)
106
+ with torch.no_grad():
107
+ out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
108
+ generated = out[:, inputs["input_ids"].shape[1] :]
109
+ return processor.batch_decode(generated, skip_special_tokens=True)[0].strip()
110
+
111
+
112
+ def main() -> int:
113
+ job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
114
+ destination = Path(sys.argv[2])
115
+
116
+ # `describe.device()`; absent is what every job before the key meant.
117
+ # Refused before torch is imported: the 4-bit load below is bitsandbytes,
118
+ # which has no backend but CUDA, and an unquantised load on another device
119
+ # is a different memory budget and a different model output — a decision
120
+ # for whoever measures one (docs/plans/PORTABILITY.md step 3).
121
+ device = job.get("device") or "cuda"
122
+ if not device.startswith("cuda"):
123
+ sys.stderr.write(
124
+ f"the vision model loads 4-bit through bitsandbytes, which is CUDA-only; "
125
+ f"device {device!r} has no path in this worker yet\n"
126
+ )
127
+ return 2
128
+
129
+ model, processor = _load(job["model"], device)
130
+
131
+ results = []
132
+ for done, window in enumerate(job["windows"], 1):
133
+ try:
134
+ frames = _frames(window["media"], window["timestamps"], job["frame_size"])
135
+ text = _generate(
136
+ model,
137
+ processor,
138
+ frames,
139
+ job["prompt"],
140
+ max_new_tokens=job["max_new_tokens"],
141
+ device=device,
142
+ )
143
+ results.append({"index": window["index"], "text": text})
144
+ except Exception as exc: # noqa: BLE001 — reported per window, not fatal
145
+ results.append({"index": window["index"], "error": f"{type(exc).__name__}: {exc}"})
146
+ # Freeing between windows is what keeps peak VRAM at one window's
147
+ # worth rather than the run's. This box has 11.5 GiB usable and an
148
+ # always-on llama-server holding 3.5 of it.
149
+ _empty_cache()
150
+ _progress(done, len(job["windows"]))
151
+
152
+ destination.write_text(json.dumps({"results": results}), encoding="utf-8")
153
+ return 0
154
+
155
+
156
+ def _progress(done: int, total: int) -> None:
157
+ """`proofcut.progress.MARKER`'s line, restated: this runs under another
158
+ interpreter and imports nothing from proofcut."""
159
+ sys.stderr.write(f"proofcut-progress {done} {total}\n")
160
+ sys.stderr.flush()
161
+
162
+
163
+ def _empty_cache() -> None:
164
+ import torch
165
+
166
+ torch.cuda.empty_cache()
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())