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.
- cutpoint/AGENTS.md +139 -0
- cutpoint/__init__.py +16 -0
- cutpoint/__main__.py +8 -0
- cutpoint/activity.py +476 -0
- cutpoint/analyze.py +1151 -0
- cutpoint/assemble.py +442 -0
- cutpoint/backends.py +52 -0
- cutpoint/boundaries.py +666 -0
- cutpoint/cache.py +109 -0
- cutpoint/cli.py +920 -0
- cutpoint/clips.py +836 -0
- cutpoint/config.py +240 -0
- cutpoint/crash.py +1193 -0
- cutpoint/crop.py +149 -0
- cutpoint/dsp.py +16 -0
- cutpoint/encode.py +216 -0
- cutpoint/events/__init__.py +1 -0
- cutpoint/events/audio_events.py +369 -0
- cutpoint/events/cli.py +249 -0
- cutpoint/events/common.py +311 -0
- cutpoint/events/discover.py +570 -0
- cutpoint/events/killfeed.py +1366 -0
- cutpoint/events/llama_server.py +283 -0
- cutpoint/events/paths.py +131 -0
- cutpoint/events/pipeline.py +492 -0
- cutpoint/events/presets/wt.json +90 -0
- cutpoint/events/regions.py +354 -0
- cutpoint/events/report.py +192 -0
- cutpoint/events/scoring.py +138 -0
- cutpoint/events/setup.py +224 -0
- cutpoint/events/sheets.py +171 -0
- cutpoint/events/tails.py +179 -0
- cutpoint/events/visual.py +782 -0
- cutpoint/events/vlm_windows.py +869 -0
- cutpoint/events/voice.py +363 -0
- cutpoint/extend.py +448 -0
- cutpoint/extras.py +54 -0
- cutpoint/ffmpeg_utils.py +1061 -0
- cutpoint/mix.py +768 -0
- cutpoint/pano/__init__.py +15 -0
- cutpoint/pano/check.py +56 -0
- cutpoint/pano/cli.py +710 -0
- cutpoint/pano/director.py +1319 -0
- cutpoint/pano/geometry.py +687 -0
- cutpoint/pano/project.py +470 -0
- cutpoint/pano/qa.py +954 -0
- cutpoint/pano/qa_paths.py +384 -0
- cutpoint/pano/render.py +574 -0
- cutpoint/pano/repair.py +488 -0
- cutpoint/pano/stitch.py +778 -0
- cutpoint/pano/views.py +936 -0
- cutpoint/pano/vlcheck.py +290 -0
- cutpoint/pano/x3.py +273 -0
- cutpoint/paths.py +142 -0
- cutpoint/planning.py +276 -0
- cutpoint/presets/activities/base.json +67 -0
- cutpoint/presets/activities/generic.json +21 -0
- cutpoint/presets/activities/hike.json +34 -0
- cutpoint/presets/activities/moto.json +40 -0
- cutpoint/presets/activities/mtb.json +43 -0
- cutpoint/presets/activities/skate.json +35 -0
- cutpoint/presets/activities/ski.json +57 -0
- cutpoint/presets/activities/snowboard.json +37 -0
- cutpoint/presets/activities/surf.json +37 -0
- cutpoint/refmatch.py +1345 -0
- cutpoint/ski.py +688 -0
- cutpoint/sources.py +166 -0
- cutpoint/stage.py +235 -0
- cutpoint/state.py +90 -0
- cutpoint/styles.py +370 -0
- cutpoint/tail.py +259 -0
- cutpoint/telemetry.py +863 -0
- cutpoint/timeline.py +140 -0
- cutpoint/transcribe.py +215 -0
- cutpoint/utils.py +95 -0
- cutpoint/vision.py +211 -0
- cutpoint-0.1.0.dist-info/METADATA +137 -0
- cutpoint-0.1.0.dist-info/RECORD +81 -0
- cutpoint-0.1.0.dist-info/WHEEL +4 -0
- cutpoint-0.1.0.dist-info/entry_points.txt +2 -0
- 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
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
|