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.
- proofcut/__init__.py +14 -0
- proofcut/_face_worker.py +109 -0
- proofcut/_tts_worker.py +108 -0
- proofcut/_vlm_worker.py +170 -0
- proofcut/asr.py +711 -0
- proofcut/autoeditor.py +309 -0
- proofcut/briefs.py +214 -0
- proofcut/captions.py +908 -0
- proofcut/cli.py +3345 -0
- proofcut/deps.py +36 -0
- proofcut/describe.py +308 -0
- proofcut/doctor.py +1059 -0
- proofcut/duck.py +149 -0
- proofcut/energy.py +499 -0
- proofcut/faces.py +352 -0
- proofcut/finish.py +362 -0
- proofcut/finishlog.py +118 -0
- proofcut/fonts/FONTS.md +96 -0
- proofcut/fonts/OFL-Outfit.txt +93 -0
- proofcut/fonts/Outfit[wght].ttf +0 -0
- proofcut/fonts/static/Outfit-Bold.ttf +0 -0
- proofcut/fonts/static/Outfit-Regular.ttf +0 -0
- proofcut/fonts.py +601 -0
- proofcut/graphics.py +2215 -0
- proofcut/install.py +663 -0
- proofcut/media.py +1298 -0
- proofcut/mlt.py +2025 -0
- proofcut/ops.py +16721 -0
- proofcut/pack.py +327 -0
- proofcut/picture.py +1156 -0
- proofcut/progress.py +254 -0
- proofcut/project.py +967 -0
- proofcut/py.typed +0 -0
- proofcut/renderlog.py +149 -0
- proofcut/reviewserver.py +487 -0
- proofcut/server.py +5194 -0
- proofcut/speakers.py +279 -0
- proofcut/speech.py +87 -0
- proofcut/templates/bumper.portrait.svg +10 -0
- proofcut/templates/bumper.svg +10 -0
- proofcut/templates/chapter.portrait.svg +11 -0
- proofcut/templates/chapter.svg +11 -0
- proofcut/templates/endcard.portrait.svg +5 -0
- proofcut/templates/endcard.svg +5 -0
- proofcut/templates/receipt.portrait.svg +8 -0
- proofcut/templates/receipt.svg +8 -0
- proofcut/templates/rerate.portrait.svg +7 -0
- proofcut/templates/rerate.svg +7 -0
- proofcut/templates/reveal.portrait.svg +7 -0
- proofcut/templates/reveal.svg +7 -0
- proofcut/timeline.py +797 -0
- proofcut/transcript.py +584 -0
- proofcut/tts.py +279 -0
- proofcut/verify.py +235 -0
- proofcut/web/FONTS.md +26 -0
- proofcut/web/LICENSE-geist-sans.txt +92 -0
- proofcut/web/LICENSE-jetbrains-mono.txt +93 -0
- proofcut/web/LICENSE-source-serif-4.txt +93 -0
- proofcut/web/agent.js +989 -0
- proofcut/web/api.js +110 -0
- proofcut/web/app.css +4217 -0
- proofcut/web/app.js +749 -0
- proofcut/web/assets.js +745 -0
- proofcut/web/dom.js +107 -0
- proofcut/web/favicon.svg +10 -0
- proofcut/web/finish.js +493 -0
- proofcut/web/frame.js +951 -0
- proofcut/web/geist-sans-400.woff2 +0 -0
- proofcut/web/geist-sans-500.woff2 +0 -0
- proofcut/web/geist-sans-600.woff2 +0 -0
- proofcut/web/geist-sans-700.woff2 +0 -0
- proofcut/web/index.html +629 -0
- proofcut/web/jetbrains-mono.woff2 +0 -0
- proofcut/web/picker.html +45 -0
- proofcut/web/picker.js +570 -0
- proofcut/web/player.js +1152 -0
- proofcut/web/properties.js +271 -0
- proofcut/web/source-serif-4-italic.woff2 +0 -0
- proofcut/web/source-serif-4.woff2 +0 -0
- proofcut/web/theme.js +94 -0
- proofcut/web/timeline.js +2531 -0
- proofcut/web/transcript.js +1082 -0
- proofcut/webui.py +4022 -0
- proofcut-0.29.0.dist-info/METADATA +444 -0
- proofcut-0.29.0.dist-info/RECORD +88 -0
- proofcut-0.29.0.dist-info/WHEEL +4 -0
- proofcut-0.29.0.dist-info/entry_points.txt +3 -0
- 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__"]
|
proofcut/_face_worker.py
ADDED
|
@@ -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())
|
proofcut/_tts_worker.py
ADDED
|
@@ -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())
|
proofcut/_vlm_worker.py
ADDED
|
@@ -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())
|