cutpoint 0.1.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 (81) hide show
  1. cutpoint/AGENTS.md +139 -0
  2. cutpoint/__init__.py +16 -0
  3. cutpoint/__main__.py +8 -0
  4. cutpoint/activity.py +476 -0
  5. cutpoint/analyze.py +1151 -0
  6. cutpoint/assemble.py +442 -0
  7. cutpoint/backends.py +52 -0
  8. cutpoint/boundaries.py +666 -0
  9. cutpoint/cache.py +109 -0
  10. cutpoint/cli.py +920 -0
  11. cutpoint/clips.py +836 -0
  12. cutpoint/config.py +240 -0
  13. cutpoint/crash.py +1193 -0
  14. cutpoint/crop.py +149 -0
  15. cutpoint/dsp.py +16 -0
  16. cutpoint/encode.py +216 -0
  17. cutpoint/events/__init__.py +1 -0
  18. cutpoint/events/audio_events.py +369 -0
  19. cutpoint/events/cli.py +249 -0
  20. cutpoint/events/common.py +311 -0
  21. cutpoint/events/discover.py +570 -0
  22. cutpoint/events/killfeed.py +1366 -0
  23. cutpoint/events/llama_server.py +283 -0
  24. cutpoint/events/paths.py +131 -0
  25. cutpoint/events/pipeline.py +492 -0
  26. cutpoint/events/presets/wt.json +90 -0
  27. cutpoint/events/regions.py +354 -0
  28. cutpoint/events/report.py +192 -0
  29. cutpoint/events/scoring.py +138 -0
  30. cutpoint/events/setup.py +224 -0
  31. cutpoint/events/sheets.py +171 -0
  32. cutpoint/events/tails.py +179 -0
  33. cutpoint/events/visual.py +782 -0
  34. cutpoint/events/vlm_windows.py +869 -0
  35. cutpoint/events/voice.py +363 -0
  36. cutpoint/extend.py +448 -0
  37. cutpoint/extras.py +54 -0
  38. cutpoint/ffmpeg_utils.py +1061 -0
  39. cutpoint/mix.py +768 -0
  40. cutpoint/pano/__init__.py +15 -0
  41. cutpoint/pano/check.py +56 -0
  42. cutpoint/pano/cli.py +710 -0
  43. cutpoint/pano/director.py +1319 -0
  44. cutpoint/pano/geometry.py +687 -0
  45. cutpoint/pano/project.py +470 -0
  46. cutpoint/pano/qa.py +954 -0
  47. cutpoint/pano/qa_paths.py +384 -0
  48. cutpoint/pano/render.py +574 -0
  49. cutpoint/pano/repair.py +488 -0
  50. cutpoint/pano/stitch.py +778 -0
  51. cutpoint/pano/views.py +936 -0
  52. cutpoint/pano/vlcheck.py +290 -0
  53. cutpoint/pano/x3.py +273 -0
  54. cutpoint/paths.py +142 -0
  55. cutpoint/planning.py +276 -0
  56. cutpoint/presets/activities/base.json +67 -0
  57. cutpoint/presets/activities/generic.json +21 -0
  58. cutpoint/presets/activities/hike.json +34 -0
  59. cutpoint/presets/activities/moto.json +40 -0
  60. cutpoint/presets/activities/mtb.json +43 -0
  61. cutpoint/presets/activities/skate.json +35 -0
  62. cutpoint/presets/activities/ski.json +57 -0
  63. cutpoint/presets/activities/snowboard.json +37 -0
  64. cutpoint/presets/activities/surf.json +37 -0
  65. cutpoint/refmatch.py +1345 -0
  66. cutpoint/ski.py +688 -0
  67. cutpoint/sources.py +166 -0
  68. cutpoint/stage.py +235 -0
  69. cutpoint/state.py +90 -0
  70. cutpoint/styles.py +370 -0
  71. cutpoint/tail.py +259 -0
  72. cutpoint/telemetry.py +863 -0
  73. cutpoint/timeline.py +140 -0
  74. cutpoint/transcribe.py +215 -0
  75. cutpoint/utils.py +95 -0
  76. cutpoint/vision.py +211 -0
  77. cutpoint-0.1.0.dist-info/METADATA +137 -0
  78. cutpoint-0.1.0.dist-info/RECORD +81 -0
  79. cutpoint-0.1.0.dist-info/WHEEL +4 -0
  80. cutpoint-0.1.0.dist-info/entry_points.txt +2 -0
  81. cutpoint-0.1.0.dist-info/licenses/LICENSE +21 -0
cutpoint/AGENTS.md ADDED
@@ -0,0 +1,139 @@
1
+ # Cutpoint for AI agents
2
+
3
+ You are an AI agent asked to edit video with Cutpoint. This file is what you need. Read this any
4
+ time with `cutpoint guide`. If you are instead changing this repository's code, read
5
+ [CONTRIBUTING.md](https://github.com/FerventOrange/cutpoint/blob/main/CONTRIBUTING.md).
6
+
7
+ Cutpoint finds the best moments in long recordings, exports them as numbered clips and
8
+ assembles a finished video. It runs locally, never modifies source videos, and writes
9
+ everything into a project folder.
10
+
11
+ ## Status: what you can rely on
12
+
13
+ | Available now | Planned (do not call; it does not exist yet) |
14
+ |---|---|
15
+ | CLI `cutpoint` with the commands below | `cutpoint edit`, `cutpoint init` |
16
+ | Result files in the project folder (JSON and text) | JSON on stdout for every command |
17
+ | Activity profiles for crash detection: `cutpoint profile`, `--context` on `profile` and `crashes` | Agent skill, profiles for the other layers and for games |
18
+
19
+ If the user asks for "edit this video and make it a montage" in one step, do it with the loop
20
+ below.
21
+
22
+ ## Calling the CLI
23
+
24
+ - If installed as a tool (`uv tool install cutpoint`), run `cutpoint <command> ...`; from a
25
+ clone, `uv run cutpoint <command> ...`. The examples below use `uv run cutpoint`; with a tool
26
+ install, drop `uv run`. First-time setup is in
27
+ [docs/getting-started.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/getting-started.md).
28
+ Check it works with `cutpoint paths` (or `uv run cutpoint paths`).
29
+ - Exit codes: `0` success, `1` error, `130` interrupted, `2` bad arguments.
30
+ - Logs and errors go to stderr as `HH:MM:SS LEVEL message`; errors are `ERROR` lines. Use
31
+ `cutpoint -v <command>` (flag before the command) for debug output and tracebacks.
32
+ - Paths inside a project must start with `projects\<name>\...` or be absolute; a bare name means
33
+ the project itself.
34
+ - Results are files, not stdout. Read the JSON files below instead of parsing logs.
35
+ - Commands are re-runnable. Per-source results are cached (path, size, modification time).
36
+ `clips` refuses a non-empty output folder without `--force`.
37
+ - Transcription, `events` and export can take minutes to hours. Run them in the background or
38
+ with a long timeout.
39
+ - Never modify or delete the user's source videos. You may delete or rename clips in a project's
40
+ `clips` folder during review.
41
+ - Use the real options: `cutpoint <command> --help` is authoritative;
42
+ [docs/commands.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/commands.md)
43
+ explains them.
44
+
45
+ ## The full edit
46
+
47
+ Given a request such as "edit D:\Videos\clip.mp4 into a funny-moments montage":
48
+
49
+ 1. **Pick a style** from the request: `funny`, `gaming`, `action`, `sport`, `commentary`
50
+ (long talk), `ski` (action-cam with telemetry). Footage that is not any of these: use `action`
51
+ or `gaming`.
52
+ 2. **Analyze.**
53
+
54
+ ```powershell
55
+ uv run cutpoint analyze D:\Videos\clip.mp4 --style funny --out clip-edit
56
+ ```
57
+
58
+ Writes `highlights.json` and `transcript.txt` into the project folder (`<projects>\clip-edit`, see
59
+ `cutpoint paths`).
60
+ 3. **Optional, game footage or when a model-based pass is requested:**
61
+ `uv run cutpoint events clip-edit` (needs the `events` extra and `cutpoint setup`). It
62
+ re-ranks `highlights.json` and writes `events.json` and `events.txt`. If it reports a missing
63
+ module, tell the user how to add the extra (see [When something fails](#when-something-fails)).
64
+ 4. **Choose moments.** Read `highlights.json` (`candidates` with `id`, `source`, `start`, `end`,
65
+ `score` 0-100, `label`, `transcript`; `suggested_top`) and, for banter and funny moments,
66
+ `transcript.txt` (`[file.mp4 00:12:31.4-00:12:38.9] text`). Then either:
67
+ - take the top N: `uv run cutpoint clips clip-edit --top 8` (or `--max-total 60` for a
68
+ length cap, `--ids h001 h004`), or
69
+ - export your own picks with a segments file:
70
+
71
+ ```json
72
+ [
73
+ {"source": "D:/Videos/clip.mp4", "start": 312.4, "end": 330.0, "label": "intro"},
74
+ {"source": "D:/Videos/clip.mp4", "start": 1204.0, "end": 1219.5, "label": "joke"}
75
+ ]
76
+ ```
77
+
78
+ `uv run cutpoint clips clip-edit --segments segments.json`
79
+ 5. **Review.** Clips are `clips\001_<label>.mp4`, `002_...`, and `clips\manifest.json` maps each
80
+ to its source and times. Tell the user what was exported. Delete clips the user rejects and
81
+ rename to reorder (for example `005_x.mp4` to `001b_x.mp4`).
82
+ 6. **Assemble.**
83
+
84
+ ```powershell
85
+ uv run cutpoint assemble projects\clip-edit\clips --transition fade --loudnorm
86
+ ```
87
+
88
+ Writes `final.mp4`, `final.fcpxml` and `final.otio` in the project folder.
89
+ 7. **Report** the paths of `final.mp4` and the clips folder.
90
+
91
+ Variants:
92
+
93
+ | Request | Do |
94
+ |---|---|
95
+ | Short clip or reel for social media | `clips <project> --vertical --max-total 60`, then `assemble projects\<project>\clips_vertical --captions` |
96
+ | Highlights of a specific length | `clips <project> --max-total <seconds>` |
97
+ | "Extra" instruction such as "only funny moments" or "keep the speech intact" | Choose `--style`, filter candidates by `transcript`/`label`, use `--segments`. The CLI does not parse these instructions |
98
+ | The user says what the footage is ("downhill mtb, chest mount, muddy lens") | Before `crashes`, run `profile <project> --context "<the user's words>"`; check the choice with `profile <project> --json`. Use `--activity NAME` only when the user names a sport that has a profile. Do not edit profile files |
99
+ | Room to trim in an editor | `clips ... --extend 1.5` or `extend <clips_dir> --seconds 2` |
100
+ | 360 (Insta360 X3) footage | [docs/workflows/360.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/workflows/360.md) |
101
+ | Action-cam with telemetry | [docs/workflows/action-cam.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/workflows/action-cam.md) |
102
+ | Gameplay with kill feeds | [docs/workflows/game-streams.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/workflows/game-streams.md) |
103
+
104
+ ## Project files
105
+
106
+ | File | Written by | Content |
107
+ |---|---|---|
108
+ | `highlights.json` | `analyze`, `events` | Ranked candidates and settings |
109
+ | `transcript.txt` | `analyze` | Time-stamped transcript |
110
+ | `events.json`, `events.txt` | `events` | Detected sounds, HUD events, model descriptions |
111
+ | `clips/manifest.json` | `clips`, `extend` | Per clip: source, start, end, score, label, tail, crop |
112
+ | `final.mp4`, `final.fcpxml`, `final.otio` | `assemble` | Result and timelines |
113
+ | `crashes.json`, `telemetry.json`, `mix_segments.json` | action-cam commands | See the action-cam page |
114
+ | `activity.json` | `profile`, `crashes` | Activity profile in use: `name`, `source`, `profile` |
115
+
116
+ ## When something fails
117
+
118
+ Read the `ERROR` line, then
119
+ [docs/troubleshooting/README.md](https://github.com/FerventOrange/cutpoint/blob/main/docs/troubleshooting/README.md).
120
+ The usual causes: ffmpeg not on `PATH`, a missing optional extra, Ollama not reachable (only
121
+ affects `--vision`), a non-empty output folder.
122
+
123
+ A missing extra `X` (for example `events`) is added differently per install. Tell the user the
124
+ command for theirs:
125
+
126
+ - From a clone: `uv sync --extra X`, listing every other extra in use too (the error prints this
127
+ form).
128
+ - Tool install: `uv tool install "cutpoint[X]" --reinstall`, listing every extra in use. With an
129
+ NVIDIA GPU and the `events` extra, add
130
+ `--index https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match` so torch
131
+ uses CUDA ([getting started](https://github.com/FerventOrange/cutpoint/blob/main/docs/getting-started.md#install-as-a-tool-optional)).
132
+
133
+ ## Rules
134
+
135
+ - Ask the user before running `cutpoint setup` (about 8 GB of downloads), `cutpoint stage`
136
+ (copies footage to local disk) or `clips --force` over an existing folder.
137
+ - Do not paste Insta360 SDK files, documentation or output into any file or message, other than
138
+ `error:` lines.
139
+ - Do not invent options. Check `--help`.
cutpoint/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Cutpoint: local AI highlight finding, clipping and assembly (analyze -> clips -> assemble).
2
+
3
+ :func:`reset_state` clears every per-process cache (config, stage index, tool
4
+ lookups, ...) for long-lived processes that change settings between jobs.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ from cutpoint.state import reset_state
10
+
11
+ try:
12
+ __version__ = version("cutpoint")
13
+ except PackageNotFoundError: # running from a source tree that is not installed
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ __all__ = ["__version__", "reset_state"]
cutpoint/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m cutpoint``."""
2
+
3
+ import sys
4
+
5
+ from cutpoint.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
cutpoint/activity.py ADDED
@@ -0,0 +1,476 @@
1
+ """Activity profiles: what an incident (crash, wipeout, fall, fail) looks and sounds like.
2
+
3
+ A profile is a small JSON document (any sport, game or kind of video)::
4
+
5
+ {"name": "mtb", "description": "...",
6
+ "detect": [CLIP captions that recognise this kind of footage],
7
+ "incident": {"pos": [incident captions], "neg": [normal-activity captions],
8
+ "words": {"over the bars": 1.0, "bail*": 0.8}},
9
+ "highlight": {"pos": [...], "neg": [...]}, "low_info": [...],
10
+ "exclude_base": [base entries this profile replaces], "<section>": {...}}
11
+
12
+ Every profile inherits the shared floor ``presets/activities/base.json`` (generic
13
+ incident captions and crash words); ``exclude_base`` drops floor entries the
14
+ profile replaces with its own (the ski profile has snowy versions of all of
15
+ them). Extra object sections (e.g. a future ``game`` HUD vocabulary) are kept.
16
+
17
+ :func:`resolve` picks the profile of a project, in this order:
18
+
19
+ 1. ``activity``: a library profile by name (``--activity``);
20
+ 2. the cached ``<project>/activity.json`` (unless the request changed or
21
+ ``refresh``);
22
+ 3. a library match: the cached keyframe CLIP embeddings of the visual layer
23
+ scored zero-shot against every profile's ``detect`` captions; the best
24
+ profile wins when its share and its lead over the runner-up are large enough;
25
+ 4. synthesis: the local vision model describes a few frames (CLIP zero-shot
26
+ over an open activity vocabulary when it cannot), then the local LLM writes
27
+ a new profile from that description and the user's words (``context``),
28
+ with the built-in profiles as examples. It is validated, capped and saved to
29
+ the library (``<CUTPOINT_HOME>/profiles``), so the next similar video
30
+ matches it in step 3;
31
+ 5. when the LLM is unavailable or its reply is unusable twice (the retry asks
32
+ for less): the nearest library profile, else ``generic``, recorded as
33
+ ``source: fallback`` so the next run tries again.
34
+
35
+ ``context`` always goes through the LLM: with a profile from steps 1-3 its
36
+ output is added on top of that profile. Built-in profiles live in
37
+ ``presets/activities``; library files of the same name override them.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ from collections.abc import Callable
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ import numpy as np
49
+
50
+ from cutpoint import cache, paths, state
51
+ from cutpoint.config import remap_source
52
+ from cutpoint.events.common import load_highlights, unload_ollama
53
+ from cutpoint.events.visual import load_visual, text_embeddings
54
+ from cutpoint.ffmpeg_utils import FFmpegError, grab_frame_jpeg
55
+ from cutpoint.utils import slugify
56
+ from cutpoint.vision import VisionRater
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ PROFILE_VERSION = 1
61
+ ACTIVITY_FILE = "activity.json"
62
+ BUILTIN_DIR = Path(__file__).parent / "presets" / "activities"
63
+ MAX_PROMPTS = 16 # per caption list
64
+ MAX_WORDS = 40
65
+ MAX_PROMPT_CHARS = 100
66
+ MAX_WORD_CHARS = 40
67
+ MAX_CONTEXT_CHARS = 500
68
+ MATCH_SHARE = 0.5 # library match: mean zero-shot share of the best profile ...
69
+ MATCH_MARGIN = 0.2 # ... and its lead over the runner-up
70
+ DESCRIBE_FRAMES = 4
71
+
72
+ # (prompt, JPEG frames, JSON schema of the reply or None) -> the model's JSON reply, or None.
73
+ Ask = Callable[[str, list[bytes], dict[str, Any] | None], dict[str, Any] | None]
74
+ # Synthesis attempts: (incident captions, words, highlight captions) asked for; the
75
+ # retry asks for less so a reply cut off by the token limit fits.
76
+ SYNTH_SIZES = ((10, 20, 4), (6, 10, 3))
77
+ # LLM-written crash words: weight caps (a single word or ``*`` prefix matches normal
78
+ # talk more often than a phrase) and filler that says nothing about an incident.
79
+ # Sections an LLM-written profile may have (extra sections only come from files).
80
+ SCHEMA_KEYS = ("name", "description", "detect", "incident", "highlight", "low_info")
81
+ GEN_WORD_CAP = 0.6
82
+ GEN_PHRASE_CAP = 0.9
83
+ FILLER = {"down", "trail", "ride", "go", "run", "the", "a", "on", "in", "up", "it", "off", "to"}
84
+
85
+ _DESCRIBE_PROMPT = (
86
+ "These {n} frames come from one video. Describe the footage for a video editor. "
87
+ "Respond with JSON only: "
88
+ '{{"activity": "...", "setting": "...", "camera": "mount / point of view", '
89
+ '"surface": "terrain or surface", "hazards": "typical crashes, falls or failures"}}'
90
+ )
91
+ _SYNTH_PROMPT = """You write detection profiles for an automatic video editor. A profile tells a \
92
+ CLIP image model and a transcript matcher what an incident (a crash, wipeout, fall, fail or \
93
+ death: whatever counts as one in this footage) looks and sounds like.
94
+
95
+ Footage: {description}
96
+ What the user said about it: {context}
97
+
98
+ Write one compact JSON profile with the keys of the examples:
99
+ name (short lowercase slug of the activity), description (one sentence),
100
+ detect (3 captions that recognise this kind of footage),
101
+ incident.pos (at most {n} captions of frames during or right after an incident, as this \
102
+ camera sees them),
103
+ incident.neg (at most {n} captions of normal footage of the same activity),
104
+ incident.words (at most {w} phrases people say right after an incident in this activity -> \
105
+ weight 0-1: its crash slang and reactions, e.g. "over the bars", "lost my ski"; a trailing \
106
+ * matches word prefixes. Never plain words of the activity or of normal talk such as \
107
+ "bike", "down", "air", "rocks" or "trail"; 1.0 only when the phrase means a crash; skip \
108
+ generic ones like "you okay" or "crashed", which every profile has),
109
+ highlight.pos / highlight.neg (at most {h} captions each: highlight-worthy vs dull moments),
110
+ low_info (at most 3 captions of frames with nothing to see).
111
+ Captions are short visual descriptions (under 12 words). Where the user says what \
112
+ matters, follow it. No other text.
113
+
114
+ Examples:
115
+ {examples}"""
116
+
117
+ _FILES: dict[str, dict[str, Any] | None] = state.register_container(__name__, "_FILES", {})
118
+
119
+
120
+ # --------------------------------------------------------------------------- schema
121
+ def _texts(v: Any, n: int = MAX_PROMPTS) -> list[str]:
122
+ out: list[str] = []
123
+ for s in v if isinstance(v, list) else []:
124
+ s = " ".join(s.split()) if isinstance(s, str) else ""
125
+ if s and len(s) <= MAX_PROMPT_CHARS and s not in out:
126
+ out.append(s)
127
+ return out[:n]
128
+
129
+
130
+ def _words(v: Any) -> dict[str, float]:
131
+ out: dict[str, float] = {}
132
+ for k, w in (v if isinstance(v, dict) else {}).items():
133
+ k = " ".join(str(k).lower().split())
134
+ try:
135
+ w = float(w)
136
+ except (TypeError, ValueError):
137
+ continue
138
+ if k and len(k) <= MAX_WORD_CHARS and np.isfinite(w) and len(out) < MAX_WORDS:
139
+ out[k] = round(min(1.0, max(0.0, w)), 2)
140
+ return out
141
+
142
+
143
+ def validate(raw: Any) -> dict[str, Any]:
144
+ """Normalise a profile (built-in, library file or LLM output): known fields
145
+ typed, deduplicated and capped; extra object sections kept as they are."""
146
+ raw = raw if isinstance(raw, dict) else {}
147
+ inc = raw.get("incident") if isinstance(raw.get("incident"), dict) else {}
148
+ hl = raw.get("highlight") if isinstance(raw.get("highlight"), dict) else {}
149
+ prof = {
150
+ "name": slugify(str(raw.get("name") or ""), 40, "custom"),
151
+ "description": " ".join(str(raw.get("description") or "").split())[:300],
152
+ "detect": _texts(raw.get("detect"), 6),
153
+ "exclude_base": _texts(raw.get("exclude_base"), MAX_PROMPTS + MAX_WORDS),
154
+ "incident": {
155
+ "pos": _texts(inc.get("pos")),
156
+ "neg": _texts(inc.get("neg")),
157
+ "words": _words(inc.get("words")),
158
+ },
159
+ "highlight": {"pos": _texts(hl.get("pos")), "neg": _texts(hl.get("neg"))},
160
+ "low_info": _texts(raw.get("low_info"), 8),
161
+ }
162
+ prof.update({k: v for k, v in raw.items() if k not in prof and isinstance(v, dict)})
163
+ return prof
164
+
165
+
166
+ def merge(base: dict[str, Any], top: dict[str, Any]) -> dict[str, Any]:
167
+ """``top`` on ``base``: caption lists appended (deduplicated), words
168
+ overridden, identity (name, description, detect) from ``top``; entries in
169
+ ``top["exclude_base"]`` are dropped from ``base``."""
170
+ drop = set(top.get("exclude_base") or ())
171
+
172
+ def cat(a: list[str], b: list[str]) -> list[str]:
173
+ out = [s for s in a if s not in drop]
174
+ return out + [s for s in b if s not in out]
175
+
176
+ out = {**base, **top, "exclude_base": []}
177
+ for sec in ("incident", "highlight"):
178
+ out[sec] = {k: cat(base[sec][k], top[sec][k]) for k in ("pos", "neg")}
179
+ out["incident"]["words"] = {
180
+ k: w for k, w in base["incident"]["words"].items() if k not in drop
181
+ } | top["incident"]["words"]
182
+ out["low_info"] = cat(base["low_info"], top["low_info"])
183
+ return out
184
+
185
+
186
+ def _load(path: Path) -> dict[str, Any] | None:
187
+ key = str(path)
188
+ if key not in _FILES:
189
+ raw = cache.load_json(path)
190
+ _FILES[key] = validate({**raw, "name": path.stem}) if isinstance(raw, dict) else None
191
+ return _FILES[key]
192
+
193
+
194
+ def builtin(name: str) -> dict[str, Any]:
195
+ """A built-in profile (``presets/activities/<name>.json``); ``base`` is the floor."""
196
+ return _load(BUILTIN_DIR / f"{name}.json") # type: ignore[return-value]
197
+
198
+
199
+ def base() -> dict[str, Any]:
200
+ """The shared floor."""
201
+ return builtin("base")
202
+
203
+
204
+ def full(profile: dict[str, Any]) -> dict[str, Any]:
205
+ """A library profile with the shared floor applied (what detection uses)."""
206
+ return merge(base(), profile)
207
+
208
+
209
+ def library() -> dict[str, dict[str, Any]]:
210
+ """Built-in profiles, then ``<CUTPOINT_HOME>/profiles/*.json`` (same name overrides)."""
211
+ out: dict[str, dict[str, Any]] = {}
212
+ for d in (BUILTIN_DIR, paths.profiles_dir()):
213
+ for f in sorted(d.glob("*.json")) if d.is_dir() else ():
214
+ prof = _load(f) if f.stem != "base" else None
215
+ if prof is not None:
216
+ out[f.stem] = prof
217
+ return out
218
+
219
+
220
+ def save_to_library(profile: dict[str, Any]) -> str:
221
+ """Store a synthesized profile in the library; returns its (unique) name."""
222
+ name = profile["name"]
223
+ if name == "base" or name in library():
224
+ name = f"{name}-{cache.params_hash(profile, 6)}"
225
+ path = paths.profiles_dir() / f"{name}.json"
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ cache.save_json(path, {**profile, "name": name})
228
+ _FILES.pop(str(path), None)
229
+ return name
230
+
231
+
232
+ # --------------------------------------------------------------------------- footage
233
+ def _text_emb(prompts: tuple[str, ...]) -> np.ndarray:
234
+ return text_embeddings(prompts)
235
+
236
+
237
+ def keyframes(project: Path, n: int = 240) -> tuple[np.ndarray, list[tuple[Path, float]]]:
238
+ """Up to ``n`` cached keyframe embeddings ``(N, 768)`` spread over the project's
239
+ sources (visual layer), with their ``(source, time)``."""
240
+
241
+ embs, refs = [], []
242
+ for src in dict.fromkeys(s["path"] for s in load_highlights(project)["sources"]):
243
+ vd = load_visual(Path(src))
244
+ if vd is not None and len(vd.t):
245
+ embs.append(vd.mean_emb)
246
+ refs += [(Path(src), float(t)) for t in vd.t]
247
+ if not embs:
248
+ return np.zeros((0, 768), np.float32), []
249
+ idx = np.unique(np.linspace(0, len(refs) - 1, min(n, len(refs))).astype(int))
250
+ return np.concatenate(embs)[idx], [refs[i] for i in idx]
251
+
252
+
253
+ def _shares(emb: np.ndarray, groups: list[list[str]]) -> np.ndarray:
254
+ """Mean zero-shot softmax share (CLIP logit scale 100) of each caption group."""
255
+ prompts = tuple(s for g in groups for s in g)
256
+ owner = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
257
+ logits = 100.0 * emb.astype(np.float32) @ _text_emb(prompts).T
258
+ p = np.exp(logits - logits.max(1, keepdims=True))
259
+ p /= p.sum(1, keepdims=True)
260
+ return np.array([p[:, owner == i].sum(1).mean() for i in range(len(groups))])
261
+
262
+
263
+ def match_scores(emb: np.ndarray, lib: dict[str, dict[str, Any]]) -> dict[str, float]:
264
+ """Zero-shot share of every library profile with ``detect`` captions."""
265
+ names = [k for k, p in lib.items() if p["detect"]]
266
+ if not len(emb) or not names:
267
+ return {}
268
+ shares = _shares(emb, [lib[k]["detect"] for k in names])
269
+ return {k: round(float(s), 3) for k, s in zip(names, shares, strict=True)}
270
+
271
+
272
+ def nearest(scores: dict[str, float]) -> str:
273
+ """Closest library profile when there is no clear match: the best one with at least
274
+ a fifth of the share, else ``generic``."""
275
+ best = max(scores.items(), key=lambda kv: kv[1], default=("generic", 0.0))
276
+ return best[0] if best[1] >= 0.2 else "generic"
277
+
278
+
279
+ def pick(scores: dict[str, float]) -> str | None:
280
+ """Best profile if it is clear (share and margin), else None; ``generic`` is no match."""
281
+ ranked = sorted(scores.items(), key=lambda kv: -kv[1])
282
+ if not ranked or ranked[0][0] == "generic":
283
+ return None
284
+ lead = ranked[0][1] - (ranked[1][1] if len(ranked) > 1 else 0.0)
285
+ return ranked[0][0] if ranked[0][1] >= MATCH_SHARE and lead >= MATCH_MARGIN else None
286
+
287
+
288
+ def describe(emb: np.ndarray, refs: list[tuple[Path, float]], ask: Ask | None) -> tuple[str, str]:
289
+ """``(description, how)``: the vision model on a few frames (``vl``), else CLIP
290
+ zero-shot over the open activity vocabulary of ``base.json`` (``clip``)."""
291
+ if ask is not None and refs:
292
+ pick_i = np.linspace(0, len(refs) - 1, DESCRIBE_FRAMES + 2)[1:-1].astype(int)
293
+ try:
294
+ imgs = [grab_frame_jpeg(remap_source(refs[i][0]), refs[i][1], 512) for i in pick_i]
295
+ except (OSError, FFmpegError) as err:
296
+ logger.info("activity: frame grab failed (%s); describing with CLIP", err)
297
+ imgs = []
298
+ reply = ask(_DESCRIBE_PROMPT.format(n=len(imgs)), imgs, None) if imgs else None
299
+ if reply:
300
+ text = "; ".join(f"{k}: {reply[k]}" for k in reply if isinstance(reply[k], str))
301
+ if text:
302
+ return text[:400], "vl"
303
+ vocab = [str(v) for v in json.loads((BUILTIN_DIR / "base.json").read_text())["vocabulary"]]
304
+ if not len(emb):
305
+ return "", "none"
306
+ shares = _shares(emb, [[f"a first-person video of {v}"] for v in vocab])
307
+ top = np.argsort(-shares)[:3]
308
+ return "footage of " + ", ".join(f"{vocab[i]} ({shares[i]:.2f})" for i in top), "clip"
309
+
310
+
311
+ def _schema(n: int, w: int, h: int) -> dict[str, Any]:
312
+ """JSON schema of a synthesized profile (Ollama structured output)."""
313
+
314
+ def arr(k: int) -> dict[str, Any]:
315
+ return {"type": "array", "items": {"type": "string"}, "maxItems": k}
316
+
317
+ def obj(props: dict[str, Any]) -> dict[str, Any]:
318
+ return {"type": "object", "properties": props, "required": list(props)}
319
+
320
+ words = {"type": "object", "additionalProperties": {"type": "number"}, "maxProperties": w}
321
+ return obj(
322
+ {
323
+ "name": {"type": "string"},
324
+ "description": {"type": "string"},
325
+ "detect": arr(3),
326
+ "incident": obj({"pos": arr(n), "neg": arr(n), "words": words}),
327
+ "highlight": obj({"pos": arr(h), "neg": arr(h)}),
328
+ "low_info": arr(3),
329
+ }
330
+ )
331
+
332
+
333
+ def _cap_words(words: dict[str, float], neg: list[str]) -> dict[str, float]:
334
+ """Generated words capped (single word / prefix 0.6, phrase 0.9); dropped: weight
335
+ 0, filler-only phrases, prefixes shorter than 3 letters and single words of the
336
+ normal-activity captions."""
337
+ normal = {t for c in neg for t in c.lower().split()}
338
+ out = {}
339
+ for k, w in words.items():
340
+ toks = k.rstrip("*").split()
341
+ if (
342
+ w <= 0
343
+ or all(t in FILLER for t in toks)
344
+ or (k.endswith("*") and len(k.rstrip("*")) < 3)
345
+ or (len(toks) == 1 and toks[0] in normal)
346
+ ):
347
+ continue
348
+ out[k] = min(w, GEN_WORD_CAP if len(toks) == 1 or "*" in k else GEN_PHRASE_CAP)
349
+ return out
350
+
351
+
352
+ def synthesize(
353
+ description: str, context: str | None, ask: Ask, seeds: list[dict[str, Any]]
354
+ ) -> dict[str, Any] | None:
355
+ """A new profile from the LLM (validated and capped), or None after one shorter retry."""
356
+ examples = "\n".join(
357
+ json.dumps({k: v for k, v in s.items() if k != "exclude_base"}) for s in seeds
358
+ )
359
+ for n, w, h in SYNTH_SIZES:
360
+ prompt = _SYNTH_PROMPT.format(
361
+ description=description or "unknown",
362
+ context=context or "nothing",
363
+ examples=examples,
364
+ n=n,
365
+ w=w,
366
+ h=h,
367
+ )
368
+ prof = validate(ask(prompt, [], _schema(n, w, h)))
369
+ if prof["incident"]["pos"]:
370
+ break
371
+ logger.info("activity: no usable profile from the LLM (%d captions asked)", n)
372
+ else:
373
+ return None
374
+ prof = {k: prof[k] for k in SCHEMA_KEYS} | {"exclude_base": []}
375
+ prof["incident"]["words"] = _cap_words(prof["incident"]["words"], prof["incident"]["neg"])
376
+ if not prof["detect"] and prof["description"]:
377
+ prof["detect"] = [prof["description"][:MAX_PROMPT_CHARS]]
378
+ return prof
379
+
380
+
381
+ def default_ask() -> tuple[Ask | None, str | None]:
382
+ """The local Ollama vision model as an :data:`Ask` (``(None, None)`` if unavailable)."""
383
+ rater = VisionRater()
384
+ return (rater.ask_json, rater.model) if rater.available() else (None, None)
385
+
386
+
387
+ # --------------------------------------------------------------------------- resolve
388
+ def resolve(
389
+ project: Path,
390
+ *,
391
+ activity: str | None = None,
392
+ context: str | None = None,
393
+ refresh: bool = False,
394
+ ask: Ask | None = None,
395
+ llm: bool = True,
396
+ ) -> dict[str, Any]:
397
+ """Profile of a project, cached in ``<project>/activity.json`` (see the module doc).
398
+
399
+ Args:
400
+ project: Project folder (highlights.json).
401
+ activity: Library profile name (overrides detection).
402
+ context: The user's own words about the video (fed to the LLM).
403
+ refresh: Ignore activity.json.
404
+ ask: Vision / text model (default: local Ollama when ``llm``).
405
+ llm: Allow the local models (False: library and generic only).
406
+
407
+ Returns:
408
+ The activity.json record; ``record["profile"]`` is the full profile.
409
+ """
410
+ path = project / ACTIVITY_FILE
411
+ old = None if refresh else cache.load_json(path)
412
+ if not isinstance(old, dict) or old.get("version") != PROFILE_VERSION:
413
+ old = None
414
+ elif old.get("source") == "fallback" and not (activity or context):
415
+ # A failed synthesis is retried with the request that asked for it.
416
+ req = old.get("request") or {}
417
+ activity, context = req.get("activity"), req.get("context")
418
+ lib = library()
419
+ if activity and activity not in lib:
420
+ raise ValueError(f"unknown activity {activity!r}; known: {', '.join(sorted(lib))}")
421
+ context = " ".join(context.split())[:MAX_CONTEXT_CHARS] if context else None
422
+ request = {"activity": activity, "context": context}
423
+ if (
424
+ old is not None
425
+ and old.get("source") != "fallback"
426
+ and (old.get("request") == request or not (activity or context))
427
+ ):
428
+ return old
429
+ rec: dict[str, Any] = {"version": PROFILE_VERSION, "request": request}
430
+ seed, emb, refs = None, np.zeros((0, 768), np.float32), []
431
+ if activity:
432
+ seed, rec["source"] = activity, "flag"
433
+ else:
434
+ emb, refs = keyframes(project)
435
+ rec["scores"] = match_scores(emb, lib)
436
+ seed = pick(rec["scores"])
437
+ rec["source"] = "library" if seed else "fallback"
438
+ profile = full(lib[seed or nearest(rec["scores"])])
439
+ if (seed is None or context) and llm:
440
+ rec["source"] = "fallback" # until the LLM delivers (retried on the next run)
441
+ model = None
442
+ if ask is None:
443
+ ask, model = default_ask()
444
+ if ask is not None:
445
+ if seed:
446
+ desc, how = lib[seed]["description"], "profile"
447
+ else:
448
+ desc, how = describe(emb, refs, ask)
449
+ # Examples: the seed, else the two closest library profiles (the model copies
450
+ # from them, so unrelated ones leak their slang).
451
+ near = sorted(rec.get("scores", {}).items(), key=lambda kv: -kv[1])
452
+ near = [k for k, _ in near if k != "generic"] + ["mtb", "ski"]
453
+ examples = [lib[seed]] if seed else [lib[k] for k in dict.fromkeys(near[:2])]
454
+ new = synthesize(desc, context, ask, examples) if desc or context else None
455
+ rec.update(description=desc, described_by=how, model=model)
456
+ if model is not None: # our Ollama client: free its VRAM for the CLIP steps
457
+ unload_ollama()
458
+ if new is not None: # LLM words add phrases, never re-weight known ones
459
+ known = (profile if seed else base())["incident"]["words"]
460
+ new["incident"]["words"] = {
461
+ k: w for k, w in new["incident"]["words"].items() if k not in known
462
+ }
463
+ if new is not None and seed:
464
+ profile = merge(
465
+ profile, {**new, **{k: profile[k] for k in ("name", "description", "detect")}}
466
+ )
467
+ rec["source"] = "context"
468
+ elif new is not None:
469
+ seed = save_to_library(new)
470
+ profile = full({**new, "name": seed})
471
+ rec["source"] = "synthesized"
472
+ rec["name"] = profile["name"]
473
+ rec["profile"] = profile
474
+ cache.save_json(path, rec)
475
+ logger.info("activity: %s (%s)", rec["name"], rec["source"])
476
+ return rec