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 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())