frameproof 0.4.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.
- frameproof/__init__.py +24 -0
- frameproof/__main__.py +487 -0
- frameproof/analyze.py +293 -0
- frameproof/assets/agents/frameproof-adversary.md +66 -0
- frameproof/assets/skill/SKILL.md +171 -0
- frameproof/budget.py +83 -0
- frameproof/extract.py +110 -0
- frameproof/fetch.py +147 -0
- frameproof/index.py +300 -0
- frameproof/ocr.py +126 -0
- frameproof/probe.py +81 -0
- frameproof/py.typed +0 -0
- frameproof/report.py +107 -0
- frameproof/select.py +383 -0
- frameproof/transcribe.py +226 -0
- frameproof/util.py +79 -0
- frameproof/verify.py +302 -0
- frameproof-0.4.0.dist-info/METADATA +201 -0
- frameproof-0.4.0.dist-info/RECORD +23 -0
- frameproof-0.4.0.dist-info/WHEEL +5 -0
- frameproof-0.4.0.dist-info/entry_points.txt +2 -0
- frameproof-0.4.0.dist-info/licenses/LICENSE +21 -0
- frameproof-0.4.0.dist-info/top_level.txt +1 -0
frameproof/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""frameproof — агент смотрит видео и может доказать, что именно он видел.
|
|
2
|
+
|
|
3
|
+
Два обещания, которых нет у аналогов:
|
|
4
|
+
|
|
5
|
+
1. НЕТ СЛЕПЫХ ЗОН. Кадры отбираются по доле изменившихся пикселей в ячейках сетки,
|
|
6
|
+
а не по средней дельте всего кадра, плюс жёсткая страховка «ни одного промежутка
|
|
7
|
+
длиннее N секунд». Наивный `select='gt(scene,0.3)'` на скринкасте промахивается
|
|
8
|
+
примерно в 40 раз и молча пропускает целые минуты.
|
|
9
|
+
|
|
10
|
+
2. ЧЕСТНЫЙ ОТЧЁТ. Инструмент вслух говорит, какое покрытие получилось и где кадров
|
|
11
|
+
нет. В этих участках агенту запрещено утверждать, что было на экране.
|
|
12
|
+
|
|
13
|
+
3. МЕТКА ПРОВЕРЯЕМА. `[MM:SS / fNNNN]` — не оформление, а ссылка на строку индекса.
|
|
14
|
+
Механический аудит без всякой модели ловит выдуманный кадр, разъехавшийся тайм-код,
|
|
15
|
+
утверждение о кадре, который агенту не выдавали, и цитату, которой на экране нет.
|
|
16
|
+
Что механика проверить не может — отдаётся слепому субагенту: он видит только кадр
|
|
17
|
+
и утверждение, без вопроса и без чужих рассуждений.
|
|
18
|
+
|
|
19
|
+
Работает полностью офлайн: yt-dlp + ffmpeg + локальная расшифровка. Ключи не нужны.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__version__ = "0.4.0"
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__"]
|
frameproof/__main__.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""CLI.
|
|
2
|
+
|
|
3
|
+
Три команды разделены НАМЕРЕННО, и это не вкусовщина:
|
|
4
|
+
|
|
5
|
+
index строит индекс, печатает покрытие — ни одной картинки
|
|
6
|
+
search ищет по речи и по тексту с экрана — ни одной картинки
|
|
7
|
+
frames отдаёт изображения — единственная команда, которая это делает
|
|
8
|
+
|
|
9
|
+
Если бы поиск мог вернуть картинки, вся экономия исчезала бы на первом же запросе:
|
|
10
|
+
агент звал бы «поиск» и получал бы гигабайты пикселей вместо строчек текста.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .util import parse_tc, slugify, tc_short
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _work_dir(target: str, explicit: str | None) -> str:
|
|
25
|
+
if explicit:
|
|
26
|
+
return explicit
|
|
27
|
+
base = os.environ.get("FRAMEPROOF_HOME") or os.path.join(
|
|
28
|
+
os.path.expanduser("~"), ".frameproof"
|
|
29
|
+
)
|
|
30
|
+
name = os.path.splitext(os.path.basename(target))[0] if os.path.exists(target) else target
|
|
31
|
+
for junk in ("https://", "http://", "www.", "youtube.com/watch?v=", "youtu.be/"):
|
|
32
|
+
name = name.replace(junk, "")
|
|
33
|
+
return os.path.join(base, slugify(name) or "video")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
37
|
+
from .analyze import analyze
|
|
38
|
+
from .extract import extract
|
|
39
|
+
from .index import write
|
|
40
|
+
from .probe import probe
|
|
41
|
+
from .report import render
|
|
42
|
+
from .select import select_frames
|
|
43
|
+
|
|
44
|
+
out_dir = _work_dir(args.target, args.out)
|
|
45
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
title, source_url, transcript = args.target, None, None
|
|
48
|
+
video_path = args.target
|
|
49
|
+
audio_path: str | None = None
|
|
50
|
+
|
|
51
|
+
if not os.path.exists(args.target):
|
|
52
|
+
from .fetch import fetch
|
|
53
|
+
|
|
54
|
+
source_url = args.target
|
|
55
|
+
print(f"качаю: {args.target}", file=sys.stderr)
|
|
56
|
+
got = fetch(args.target, out_dir, max_height=args.max_height)
|
|
57
|
+
video_path, title, audio_path = got.video_path, got.title, got.audio_path
|
|
58
|
+
if got.subtitle_path:
|
|
59
|
+
from .transcribe import from_subtitles
|
|
60
|
+
|
|
61
|
+
transcript = from_subtitles(
|
|
62
|
+
got.subtitle_path, auto=got.subtitle_auto, lang=got.subtitle_lang
|
|
63
|
+
)
|
|
64
|
+
kind = "авто" if got.subtitle_auto else "ручные"
|
|
65
|
+
print(
|
|
66
|
+
f"субтитры: {kind}, {got.subtitle_lang}, "
|
|
67
|
+
f"{len(transcript.segments)} реплик",
|
|
68
|
+
file=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
info = probe(video_path)
|
|
72
|
+
print(
|
|
73
|
+
f"видео: {info.width}x{info.height} {info.fps:.2f} к/с "
|
|
74
|
+
f"{tc_short(info.duration)}{' VFR' if info.vfr else ''}",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if transcript is None and not args.no_transcribe:
|
|
79
|
+
transcript = _local_transcript(audio_path or video_path, out_dir, args.lang)
|
|
80
|
+
if transcript is None and not args.no_transcribe:
|
|
81
|
+
print(
|
|
82
|
+
"⚠ транскрипта нет — искать по речи будет нечему. "
|
|
83
|
+
"Текст с экрана всё равно доступен: добавьте --ocr.",
|
|
84
|
+
file=sys.stderr,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if args.fast:
|
|
88
|
+
print("быстрый проход: только ключевые кадры...", file=sys.stderr)
|
|
89
|
+
else:
|
|
90
|
+
print("анализирую изменения экрана...", file=sys.stderr)
|
|
91
|
+
sig = analyze(info, fast=args.fast)
|
|
92
|
+
|
|
93
|
+
cues: list[float] = []
|
|
94
|
+
if transcript is not None and not args.no_cues:
|
|
95
|
+
from .transcribe import pointing_cues
|
|
96
|
+
|
|
97
|
+
cues = pointing_cues(transcript)
|
|
98
|
+
if cues:
|
|
99
|
+
print(f"якорей по речи («вот здесь», «смотрите»): {len(cues)}", file=sys.stderr)
|
|
100
|
+
|
|
101
|
+
sel = select_frames(
|
|
102
|
+
sig, info.duration, max_gap=args.max_gap, cap=args.max_frames, cues=cues
|
|
103
|
+
)
|
|
104
|
+
print(f"извлекаю {len(sel.picks)} кадров...", file=sys.stderr)
|
|
105
|
+
frames = extract(info, sel.picks, os.path.join(out_dir, "frames"), width=args.width)
|
|
106
|
+
|
|
107
|
+
if args.ocr:
|
|
108
|
+
from . import ocr as ocr_mod
|
|
109
|
+
|
|
110
|
+
if ocr_mod.available():
|
|
111
|
+
print("распознаю текст на кадрах...", file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
index = write(
|
|
114
|
+
out_dir,
|
|
115
|
+
info=info,
|
|
116
|
+
selection=sel,
|
|
117
|
+
frames=frames,
|
|
118
|
+
transcript=transcript,
|
|
119
|
+
source_url=source_url,
|
|
120
|
+
title=title,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if args.ocr:
|
|
124
|
+
from . import ocr as ocr_mod
|
|
125
|
+
|
|
126
|
+
hits = ocr_mod.annotate_index(out_dir)
|
|
127
|
+
if hits:
|
|
128
|
+
print(f"текст найден на {hits} кадрах — теперь экран грепается", file=sys.stderr)
|
|
129
|
+
elif not ocr_mod.available():
|
|
130
|
+
print("OCR пропущен: нет swiftc (нужны Xcode Command Line Tools)", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
print()
|
|
133
|
+
print(render(sel, title=title[:60], frame_w=frames[0].width if frames else 0,
|
|
134
|
+
frame_h=frames[0].height if frames else 0))
|
|
135
|
+
print()
|
|
136
|
+
print(f"индекс: {out_dir}")
|
|
137
|
+
print(f"дальше: frameproof search \"<запрос>\" --out {out_dir}")
|
|
138
|
+
print(f" frameproof frames --at 4:12 --out {out_dir}")
|
|
139
|
+
return 0 if index["coverage"]["complete"] else 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _local_transcript(source_path: str, out_dir: str, lang: str | None):
|
|
143
|
+
"""Расшифровка локально. О любом провале сообщаем вслух.
|
|
144
|
+
|
|
145
|
+
Тихий возврат None здесь once уже стоил пустого индекса: скачивался video-only
|
|
146
|
+
поток без звука, ffmpeg честно не находил аудиодорожку, и пользователь получал
|
|
147
|
+
ноль реплик без единого предупреждения.
|
|
148
|
+
"""
|
|
149
|
+
from .transcribe import transcribe_audio
|
|
150
|
+
from .util import run, which
|
|
151
|
+
|
|
152
|
+
if not source_path or not os.path.exists(source_path):
|
|
153
|
+
print("расшифровка пропущена: нет файла со звуком", file=sys.stderr)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
audio = os.path.join(out_dir, "audio16k.wav")
|
|
157
|
+
if not os.path.exists(audio):
|
|
158
|
+
# check=False: у файла может просто не быть звуковой дорожки, и это
|
|
159
|
+
# нормальная ситуация, а не повод показывать человеку дамп команды.
|
|
160
|
+
run([which("ffmpeg"), "-v", "error", "-y", "-i", source_path,
|
|
161
|
+
"-vn", "-ac", "1", "-ar", "16000", audio], check=False)
|
|
162
|
+
if not os.path.exists(audio) or os.path.getsize(audio) < 1024:
|
|
163
|
+
print("в файле нет звуковой дорожки — расшифровывать нечего", file=sys.stderr)
|
|
164
|
+
return None
|
|
165
|
+
print("расшифровываю локально (ключи не нужны)...", file=sys.stderr)
|
|
166
|
+
try:
|
|
167
|
+
return transcribe_audio(audio, lang=lang)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
print(f"расшифровка не удалась: {exc}", file=sys.stderr)
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
174
|
+
from .index import load_index, search
|
|
175
|
+
|
|
176
|
+
out_dir = args.out or _work_dir(args.query, None)
|
|
177
|
+
if not os.path.exists(os.path.join(out_dir, "index.json")):
|
|
178
|
+
print(f"нет индекса в {out_dir}. Сначала: frameproof index <url|файл>", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
hits = search(out_dir, args.query, limit=args.limit)
|
|
181
|
+
if not hits:
|
|
182
|
+
print(f"не найдено: {args.query!r}")
|
|
183
|
+
idx = load_index(out_dir)
|
|
184
|
+
if not idx["transcript"]["segment_count"]:
|
|
185
|
+
print("(транскрипта в индексе нет — искать пока не по чему)")
|
|
186
|
+
return 1
|
|
187
|
+
for h in hits:
|
|
188
|
+
line = h.line()
|
|
189
|
+
print(line if len(line) <= 200 else line[:197] + "...")
|
|
190
|
+
print()
|
|
191
|
+
print(f"{len(hits)} совпадений. Ни одной картинки не загружено.")
|
|
192
|
+
print(f"Посмотреть момент: frameproof frames --at {tc_short(hits[0].t)} --out {out_dir}")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_frames(args: argparse.Namespace) -> int:
|
|
197
|
+
from .index import frames_by_ids, frames_near, load_index
|
|
198
|
+
|
|
199
|
+
out_dir = args.out
|
|
200
|
+
if not out_dir or not os.path.exists(os.path.join(out_dir, "index.json")):
|
|
201
|
+
print("нужен --out с готовым индексом", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
idx = load_index(out_dir)
|
|
204
|
+
|
|
205
|
+
if args.ids:
|
|
206
|
+
rows = frames_by_ids(out_dir, [s.strip() for s in args.ids.split(",")])
|
|
207
|
+
elif args.at:
|
|
208
|
+
rows = frames_near(out_dir, parse_tc(args.at), count=args.count)
|
|
209
|
+
else:
|
|
210
|
+
print("укажите --at 4:12 или --ids f0043", file=sys.stderr)
|
|
211
|
+
return 2
|
|
212
|
+
|
|
213
|
+
if not rows:
|
|
214
|
+
print("кадров не нашлось")
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
_log_served(out_dir, rows)
|
|
218
|
+
cov = idx["coverage"]
|
|
219
|
+
for r in rows:
|
|
220
|
+
inside = [g for g in cov["gaps"] if g["from"] <= r["t"] <= g["to"]]
|
|
221
|
+
mark = " ⚠ участок без гарантии покрытия" if inside else ""
|
|
222
|
+
print(f"[{r['tc'].split('.')[0]} / {r['id']}] {os.path.join(out_dir, r['path'])}"
|
|
223
|
+
f" ({r['est_tokens']} токенов){mark}")
|
|
224
|
+
if r.get("caption"):
|
|
225
|
+
print(f" уже разобран: {r['caption']}")
|
|
226
|
+
total = sum(r["est_tokens"] for r in rows)
|
|
227
|
+
print()
|
|
228
|
+
print(f"{len(rows)} кадров, примерно {total} визуальных токенов.")
|
|
229
|
+
print("Показывай их модели и цитируй меткой [MM:SS / fNNNN].")
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _log_served(out_dir: str, rows: list[dict]) -> None:
|
|
234
|
+
"""Журнал выданных кадров.
|
|
235
|
+
|
|
236
|
+
Нужен ровно для одной проверки: утверждение о кадре, который агенту ни разу не
|
|
237
|
+
выдавали, — это утверждение, сделанное не глядя. Поймать это можно только зная,
|
|
238
|
+
что именно инструмент отдал.
|
|
239
|
+
"""
|
|
240
|
+
import time
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
with open(os.path.join(out_dir, "served.jsonl"), "a", encoding="utf-8") as fh:
|
|
244
|
+
for r in rows:
|
|
245
|
+
fh.write(json.dumps(
|
|
246
|
+
{"id": r["id"], "t": r["t"], "at": round(time.time(), 3)},
|
|
247
|
+
ensure_ascii=False,
|
|
248
|
+
) + "\n")
|
|
249
|
+
except OSError:
|
|
250
|
+
pass # журнал — вспомогательный; его отсутствие не должно ломать выдачу
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
254
|
+
from .verify import audit, plan_second_look, render
|
|
255
|
+
|
|
256
|
+
out_dir = args.out
|
|
257
|
+
if not os.path.exists(os.path.join(out_dir, "index.json")):
|
|
258
|
+
print(f"нет индекса в {out_dir}", file=sys.stderr)
|
|
259
|
+
return 2
|
|
260
|
+
if not os.path.exists(args.answer):
|
|
261
|
+
print(f"нет файла с разбором: {args.answer}", file=sys.stderr)
|
|
262
|
+
return 2
|
|
263
|
+
with open(args.answer, encoding="utf-8") as fh:
|
|
264
|
+
answer = fh.read()
|
|
265
|
+
|
|
266
|
+
claims = audit(answer, out_dir)
|
|
267
|
+
|
|
268
|
+
if args.plan:
|
|
269
|
+
tasks = plan_second_look(claims, out_dir, limit=args.limit)
|
|
270
|
+
print(json.dumps(tasks, ensure_ascii=False, indent=2))
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
if args.json:
|
|
274
|
+
print(json.dumps(
|
|
275
|
+
[
|
|
276
|
+
{
|
|
277
|
+
"n": c.n, "text": c.text, "t": c.t, "frame_id": c.frame_id,
|
|
278
|
+
"severity": c.severity, "needs_second_look": c.needs_second_look,
|
|
279
|
+
"findings": [
|
|
280
|
+
{"code": f.code, "severity": f.severity, "detail": f.detail}
|
|
281
|
+
for f in c.findings
|
|
282
|
+
],
|
|
283
|
+
}
|
|
284
|
+
for c in claims
|
|
285
|
+
],
|
|
286
|
+
ensure_ascii=False, indent=2,
|
|
287
|
+
))
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
print(render(claims, index_dir=out_dir))
|
|
291
|
+
return 1 if any(c.severity == "FAIL" for c in claims) else 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
295
|
+
from .index import load_index
|
|
296
|
+
|
|
297
|
+
out_dir = args.out
|
|
298
|
+
idx = load_index(out_dir)
|
|
299
|
+
cov = idx["coverage"]
|
|
300
|
+
v, f = idx["video"], idx["frames"]
|
|
301
|
+
print(f"ВИДЕО: {v['title'][:70]}")
|
|
302
|
+
print(f" {tc_short(v['duration_sec'])}, {v['width']}x{v['height']}, {v['fps']} к/с")
|
|
303
|
+
print(f"КАДРЫ: {f['count']} (переходы {f['caught_changes']}, страховка {f['safety_fills']})")
|
|
304
|
+
print(f" {f['est_tokens_per_frame']} токенов на кадр")
|
|
305
|
+
if cov["complete"]:
|
|
306
|
+
print(f"ПОКРЫТИЕ: 100 % — максимальный разрыв {cov['actual_max_gap_sec']} с "
|
|
307
|
+
f"при цели {cov['max_gap_target_sec']:.0f} с")
|
|
308
|
+
else:
|
|
309
|
+
print(f"ПОКРЫТИЕ: {cov['ratio'] * 100:.0f} % — участки без кадров:")
|
|
310
|
+
for g in cov["gaps"]:
|
|
311
|
+
print(f" {g['tc']} НЕ утверждай, что было на экране здесь")
|
|
312
|
+
t = idx["transcript"]
|
|
313
|
+
print(f"ТРАНСКРИПТ: {t['segment_count']} реплик, источник {t['source']}, язык {t['language']}")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_doctor(_: argparse.Namespace) -> int:
|
|
318
|
+
import shutil
|
|
319
|
+
|
|
320
|
+
print(f"frameproof {__version__}")
|
|
321
|
+
ok = True
|
|
322
|
+
for tool, why in (("ffmpeg", "разбор видео"), ("ffprobe", "метаданные"),
|
|
323
|
+
("yt-dlp", "ссылки (для локальных файлов не нужен)")):
|
|
324
|
+
path = shutil.which(tool)
|
|
325
|
+
print(f" {'✓' if path else '✗'} {tool:<8} {path or 'НЕ НАЙДЕН'} — {why}")
|
|
326
|
+
if not path and tool != "yt-dlp":
|
|
327
|
+
ok = False
|
|
328
|
+
try:
|
|
329
|
+
import numpy
|
|
330
|
+
print(f" ✓ numpy {numpy.__version__}")
|
|
331
|
+
except ImportError:
|
|
332
|
+
print(" ✗ numpy НЕ НАЙДЕН — pip install numpy")
|
|
333
|
+
ok = False
|
|
334
|
+
for mod, why in (("mlx_whisper", "быстрая расшифровка на Apple Silicon"),
|
|
335
|
+
("whisper", "расшифровка везде"),
|
|
336
|
+
("yt_dlp", "загрузка по ссылке")):
|
|
337
|
+
try:
|
|
338
|
+
__import__(mod)
|
|
339
|
+
print(f" ✓ {mod:<12} {why}")
|
|
340
|
+
except ImportError:
|
|
341
|
+
print(f" · {mod:<12} нет — {why} недоступна")
|
|
342
|
+
from . import ocr as ocr_mod
|
|
343
|
+
print(f" {'✓' if ocr_mod.available() else '·'} Apple Vision OCR "
|
|
344
|
+
f"{'доступен' if ocr_mod.available() else 'нет swiftc — OCR пропустится'}")
|
|
345
|
+
print()
|
|
346
|
+
print("готов к работе" if ok else "не хватает обязательного — см. выше")
|
|
347
|
+
return 0 if ok else 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _link_or_copy(src: str, dst: str) -> None:
|
|
351
|
+
"""Симлинк, а где нельзя — копия. На Windows симлинки требуют режима разработчика."""
|
|
352
|
+
import shutil
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
os.symlink(src, dst)
|
|
356
|
+
except (OSError, NotImplementedError):
|
|
357
|
+
if os.path.isdir(src):
|
|
358
|
+
shutil.copytree(src, dst)
|
|
359
|
+
else:
|
|
360
|
+
shutil.copy2(src, dst)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
364
|
+
"""Ставит скилл для Claude Code симлинком на канонический каталог."""
|
|
365
|
+
pkg = os.path.dirname(os.path.abspath(__file__))
|
|
366
|
+
src = os.path.join(pkg, "assets", "skill")
|
|
367
|
+
if not os.path.exists(os.path.join(src, "SKILL.md")):
|
|
368
|
+
print(f"не нашёл SKILL.md в дистрибутиве ({src})", file=sys.stderr)
|
|
369
|
+
return 2
|
|
370
|
+
dst_dir = os.path.expanduser("~/.claude/skills")
|
|
371
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
372
|
+
dst = os.path.join(dst_dir, "frameproof")
|
|
373
|
+
if os.path.islink(dst) or os.path.exists(dst):
|
|
374
|
+
if not args.force:
|
|
375
|
+
print(f"{dst} уже существует. Перезаписать: --force", file=sys.stderr)
|
|
376
|
+
return 1
|
|
377
|
+
if os.path.islink(dst):
|
|
378
|
+
os.unlink(dst)
|
|
379
|
+
else:
|
|
380
|
+
import shutil as sh
|
|
381
|
+
sh.rmtree(dst)
|
|
382
|
+
_link_or_copy(src, dst)
|
|
383
|
+
print(f"✓ скилл поставлен: {dst} → {src}")
|
|
384
|
+
|
|
385
|
+
# Субагент-проверяющий. Claude Code ищет субагентов только в ~/.claude/agents/,
|
|
386
|
+
# рядом со скиллом он их не видит.
|
|
387
|
+
agent_src = os.path.join(pkg, "assets", "agents", "frameproof-adversary.md")
|
|
388
|
+
if os.path.exists(agent_src):
|
|
389
|
+
agents_dir = os.path.expanduser("~/.claude/agents")
|
|
390
|
+
fresh = not os.path.isdir(agents_dir)
|
|
391
|
+
os.makedirs(agents_dir, exist_ok=True)
|
|
392
|
+
agent_dst = os.path.join(agents_dir, "frameproof-adversary.md")
|
|
393
|
+
if os.path.islink(agent_dst) or os.path.exists(agent_dst):
|
|
394
|
+
if args.force:
|
|
395
|
+
os.remove(agent_dst)
|
|
396
|
+
else:
|
|
397
|
+
print(f" · {agent_dst} уже есть, пропускаю (--force чтобы перезаписать)")
|
|
398
|
+
agent_dst = ""
|
|
399
|
+
if agent_dst:
|
|
400
|
+
_link_or_copy(agent_src, agent_dst)
|
|
401
|
+
print(f"✓ проверяющий поставлен: {agent_dst}")
|
|
402
|
+
if fresh:
|
|
403
|
+
print(" ⚠ каталог ~/.claude/agents создан впервые — нужен рестарт Claude Code")
|
|
404
|
+
|
|
405
|
+
print(" В Claude Code: «посмотри это видео <ссылка>» или /frameproof")
|
|
406
|
+
return 0
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
410
|
+
p = argparse.ArgumentParser(
|
|
411
|
+
prog="frameproof",
|
|
412
|
+
description="Агент смотрит видео без слепых зон и доказывает тайм-кодом, что видел.",
|
|
413
|
+
)
|
|
414
|
+
p.add_argument("--version", action="version", version=f"frameproof {__version__}")
|
|
415
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
416
|
+
|
|
417
|
+
i = sub.add_parser("index", help="построить индекс (картинок не отдаёт)")
|
|
418
|
+
i.add_argument("target", help="ссылка или путь к файлу")
|
|
419
|
+
i.add_argument("--out", help="папка индекса")
|
|
420
|
+
i.add_argument("--max-gap", type=float, default=15.0,
|
|
421
|
+
help="гарантия: без кадра не дольше N секунд (по умолчанию 15)")
|
|
422
|
+
i.add_argument("--max-frames", type=int, default=0,
|
|
423
|
+
help="потолок кадров (0 = считать от длительности)")
|
|
424
|
+
i.add_argument("--fast", action="store_true",
|
|
425
|
+
help="в 38 раз быстрее: кандидаты берутся из ключевых кадров. "
|
|
426
|
+
"Плата — кадр встаёт туда, куда его поставил кодировщик, "
|
|
427
|
+
"а не туда, где на экране дописалась мысль")
|
|
428
|
+
i.add_argument("--width", type=int, default=1280, help="ширина кадра (1280 = 1196 токенов)")
|
|
429
|
+
i.add_argument("--max-height", type=int, default=1080, help="качество скачиваемого потока")
|
|
430
|
+
i.add_argument("--lang", default=None, help="язык для расшифровки, напр. ru")
|
|
431
|
+
i.add_argument("--ocr", action="store_true", help="распознать текст на кадрах (macOS)")
|
|
432
|
+
i.add_argument("--no-cues", action="store_true",
|
|
433
|
+
help="не ставить кадры по указательным репликам («вот здесь», «смотрите»)")
|
|
434
|
+
i.add_argument("--no-transcribe", action="store_true",
|
|
435
|
+
help="не расшифровывать, если нет субтитров")
|
|
436
|
+
i.set_defaults(func=cmd_index)
|
|
437
|
+
|
|
438
|
+
s = sub.add_parser("search", help="искать по речи и тексту с экрана (картинок не отдаёт)")
|
|
439
|
+
s.add_argument("query")
|
|
440
|
+
s.add_argument("--out", required=True, help="папка индекса")
|
|
441
|
+
s.add_argument("--limit", type=int, default=20)
|
|
442
|
+
s.set_defaults(func=cmd_search)
|
|
443
|
+
|
|
444
|
+
f = sub.add_parser("frames", help="отдать кадры — единственная команда с картинками")
|
|
445
|
+
f.add_argument("--out", required=True, help="папка индекса")
|
|
446
|
+
f.add_argument("--at", help="момент: 4:12 / 1:04:12 / 252")
|
|
447
|
+
f.add_argument("--ids", help="идентификаторы через запятую: f0043,f0044")
|
|
448
|
+
f.add_argument("--count", type=int, default=1, help="сколько кадров вокруг момента")
|
|
449
|
+
f.set_defaults(func=cmd_frames)
|
|
450
|
+
|
|
451
|
+
r = sub.add_parser("report", help="показать покрытие готового индекса")
|
|
452
|
+
r.add_argument("--out", required=True)
|
|
453
|
+
r.set_defaults(func=cmd_report)
|
|
454
|
+
|
|
455
|
+
v = sub.add_parser("verify", help="проверить утверждения разбора против индекса")
|
|
456
|
+
v.add_argument("answer", help="файл с разбором, который написал агент")
|
|
457
|
+
v.add_argument("--out", required=True, help="папка индекса")
|
|
458
|
+
v.add_argument("--json", action="store_true", help="машиночитаемый вывод")
|
|
459
|
+
v.add_argument("--plan", action="store_true",
|
|
460
|
+
help="выдать задания для слепого проверяющего вместо отчёта")
|
|
461
|
+
v.add_argument("--limit", type=int, default=8,
|
|
462
|
+
help="потолок утверждений для второго взгляда (по умолчанию 8)")
|
|
463
|
+
v.set_defaults(func=cmd_verify)
|
|
464
|
+
|
|
465
|
+
d = sub.add_parser("doctor", help="проверить окружение")
|
|
466
|
+
d.set_defaults(func=cmd_doctor)
|
|
467
|
+
|
|
468
|
+
n = sub.add_parser("install", help="поставить скилл в Claude Code")
|
|
469
|
+
n.add_argument("--force", action="store_true")
|
|
470
|
+
n.set_defaults(func=cmd_install)
|
|
471
|
+
return p
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def main(argv: list[str] | None = None) -> int:
|
|
475
|
+
args = build_parser().parse_args(argv)
|
|
476
|
+
try:
|
|
477
|
+
return args.func(args)
|
|
478
|
+
except KeyboardInterrupt:
|
|
479
|
+
print("\nпрервано", file=sys.stderr)
|
|
480
|
+
return 130
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
print(f"ошибка: {exc}", file=sys.stderr)
|
|
483
|
+
return 1
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
if __name__ == "__main__":
|
|
487
|
+
raise SystemExit(main())
|