terpsi 0.0.2__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.
terpsi/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """terpsi: dance corpora from single-dancer videos.
2
+
3
+ A clip (a YouTube URL, a Wikimedia Commons URL, a local file) is reduced to what a
4
+ dance model needs and nothing that a video needs: a landmark time series (body, hands,
5
+ face, with metric 3D world coordinates where the model gives them), the audio, the
6
+ description and metadata, the subtitles, and the timestamped text that was on screen.
7
+ The video itself is deleted after extraction. On top of the corpus: movement-versus-music
8
+ analysis (kinematic beats, beat alignment, phase), for dance tutors, dance games and the
9
+ study of how bodies move to music.
10
+
11
+ Five seams, each one keyword argument with a working default:
12
+
13
+ - ``downloader`` (:func:`terpsi.acquire.fetch`): ``yb`` over yt-dlp.
14
+ - ``extractor`` (:func:`terpsi.pose.extract_landmarks`): MediaPipe ``HolisticLandmarker``.
15
+ - ``ocr`` (:func:`terpsi.screen_text.read_screen_text`): an ``ocracy`` backend.
16
+ - ``corpus`` (:class:`terpsi.store.Corpus`): a folder under ``~/.local/share/terpsi``.
17
+ - ``tracker`` (:func:`terpsi.audio.track_beats`): librosa's beat tracker.
18
+
19
+ >>> from terpsi import ingest, Corpus # doctest: +SKIP
20
+ >>> clip_id = ingest("https://youtu.be/q_TUyxUhoEw") # doctest: +SKIP
21
+ >>> Corpus()[clip_id].report["beat_alignment"] # doctest: +SKIP
22
+ """
23
+
24
+ from terpsi.config import data_dir, clips_dir
25
+ from terpsi.store import Corpus, Clip
26
+ from terpsi.pose import (
27
+ LandmarkSeries,
28
+ extract_landmarks,
29
+ POSE_LANDMARKS,
30
+ HAND_LANDMARKS,
31
+ )
32
+ from terpsi.analysis import (
33
+ motion_energy,
34
+ kinematic_beats,
35
+ beat_alignment_score,
36
+ phase_concentration,
37
+ alignment_report,
38
+ )
39
+ from terpsi.pipeline import ingest, screen_clip
40
+
41
+ __all__ = [
42
+ "data_dir",
43
+ "clips_dir",
44
+ "Corpus",
45
+ "Clip",
46
+ "LandmarkSeries",
47
+ "extract_landmarks",
48
+ "POSE_LANDMARKS",
49
+ "HAND_LANDMARKS",
50
+ "motion_energy",
51
+ "kinematic_beats",
52
+ "beat_alignment_score",
53
+ "phase_concentration",
54
+ "alignment_report",
55
+ "ingest",
56
+ "screen_clip",
57
+ ]
terpsi/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """CLI entry point: ``python -m terpsi`` / the ``terpsi`` script, over :mod:`terpsi.tools`."""
3
+
4
+ from __future__ import annotations
5
+
6
+
7
+ def main(argv=None) -> int:
8
+ """Dispatch a command from :data:`terpsi.tools._dispatch_funcs` and return its exit code."""
9
+ import cw
10
+
11
+ from terpsi.tools import _dispatch_funcs
12
+
13
+ return cw.dispatch(_dispatch_funcs, argv, prog="terpsi")
14
+
15
+
16
+ if __name__ == "__main__":
17
+ raise SystemExit(main())
terpsi/acquire.py ADDED
@@ -0,0 +1,222 @@
1
+ """Getting a clip and its sidecars: the ``downloader`` seam.
2
+
3
+ :func:`fetch` turns a source (a YouTube URL or id, a Wikimedia Commons URL, a local
4
+ file) into a :class:`ClipFiles`: the video path plus whatever sidecars exist (yt-dlp's
5
+ info.json, the description, subtitles). The default downloader is ``yb`` (yt-dlp with
6
+ sidecars); a local file passes through, picking up sidecars that sit next to it.
7
+
8
+ :func:`search_youtube` lists candidate clips for a query, by default restricted to the
9
+ Creative Commons licence filter of YouTube search, so licence-clear clips come first.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Callable, Iterable
19
+
20
+ PathLike = "str | Path"
21
+
22
+ _YT_ID = re.compile(r"(?:v=|youtu\.be/|/shorts/|^)([A-Za-z0-9_-]{11})(?:[&?#]|$)")
23
+ _CC_FILTER = (
24
+ "EgIwAQ%253D%253D" # YouTube search 'sp' parameter: Creative Commons licence
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class ClipFiles:
30
+ """A fetched clip: the video, its id, and the sidecars that came with it."""
31
+
32
+ video: Path
33
+ clip_id: str
34
+ source: str
35
+ info_json: Path | None = None
36
+ description: Path | None = None
37
+ subtitles: list[Path] = field(default_factory=list)
38
+ downloaded: bool = False # True when fetch() downloaded it (so it may be deleted)
39
+
40
+ @property
41
+ def info(self) -> dict:
42
+ """The yt-dlp info dict (empty when there is no info.json)."""
43
+ if self.info_json and self.info_json.exists():
44
+ return json.loads(self.info_json.read_text())
45
+ return {}
46
+
47
+
48
+ def youtube_id(source: str) -> str | None:
49
+ """The 11-character YouTube id in ``source``, or None."""
50
+ m = _YT_ID.search(source)
51
+ return m.group(1) if m else None
52
+
53
+
54
+ def _sidecars_next_to(video: Path) -> dict:
55
+ import glob as _glob
56
+
57
+ stem = video.with_suffix("")
58
+ esc = _glob.escape(video.stem)
59
+ subs = sorted(
60
+ Path(p) for p in _glob.glob(str(video.parent / f"{esc}.*.vtt"))
61
+ ) + sorted(Path(p) for p in _glob.glob(str(video.parent / f"{esc}.*.srt")))
62
+ info = Path(f"{stem}.info.json")
63
+ desc = Path(f"{stem}.description")
64
+ return {
65
+ "info_json": info if info.exists() else None,
66
+ "description": desc if desc.exists() else None,
67
+ "subtitles": subs,
68
+ }
69
+
70
+
71
+ def yb_downloader(
72
+ source: str,
73
+ dest: Path,
74
+ *,
75
+ subtitle_langs: tuple[str, ...] = ("en",),
76
+ max_height: int | None = 720,
77
+ **opts,
78
+ ) -> ClipFiles:
79
+ """The default downloader: ``yb`` over yt-dlp, with every sidecar it can write.
80
+
81
+ ``max_height`` caps the video resolution (720 by default: landmarks are detected on
82
+ a 640-px frame and captions read at 1280 px, so 1080p buys nothing but bandwidth).
83
+ """
84
+ from yb.download import download_youtube_video
85
+
86
+ if max_height and "fmt" not in opts:
87
+ # H.264 first: OpenCV decodes it everywhere, AV1 not always (an undecodable video is an empty series)
88
+ h = f"[height<={max_height}]"
89
+ opts["fmt"] = (
90
+ f"bestvideo{h}[vcodec^=avc1]+bestaudio/bestvideo{h}+bestaudio/best{h}/best"
91
+ )
92
+ r = download_youtube_video(
93
+ source,
94
+ download_dir=dest,
95
+ write_info_json=True,
96
+ write_description=True,
97
+ write_subtitles=True,
98
+ write_auto_subtitles=True,
99
+ subtitle_langs=subtitle_langs,
100
+ **opts,
101
+ )
102
+ video = Path(r.path)
103
+ found = _sidecars_next_to(video)
104
+ sc = r.sidecars or {}
105
+ subs = sc.get("subtitles") or []
106
+ subs = [Path(p) for p in (subs if isinstance(subs, (list, tuple)) else [subs])]
107
+ clip_id = (r.info or {}).get("id") or youtube_id(source) or video.stem
108
+ return ClipFiles(
109
+ video=video,
110
+ clip_id=clip_id,
111
+ source=source,
112
+ info_json=Path(sc["info_json"]) if sc.get("info_json") else found["info_json"],
113
+ description=Path(sc["description"])
114
+ if sc.get("description")
115
+ else found["description"],
116
+ subtitles=subs or found["subtitles"],
117
+ downloaded=True,
118
+ )
119
+
120
+
121
+ def fetch(
122
+ source: str | Path,
123
+ *,
124
+ dest: Path | None = None,
125
+ downloader: Callable[..., ClipFiles] | None = None,
126
+ **opts,
127
+ ) -> ClipFiles:
128
+ """Fetch ``source`` into ``dest`` and return its :class:`ClipFiles`.
129
+
130
+ A path to an existing file is returned as is (with sidecars found next to it);
131
+ anything else goes through ``downloader`` (default :func:`yb_downloader`).
132
+ """
133
+ p = Path(source).expanduser()
134
+ if p.exists():
135
+ found = _sidecars_next_to(p)
136
+ clip_id = None
137
+ if found["info_json"]:
138
+ clip_id = json.loads(found["info_json"].read_text()).get("id")
139
+ clip_id = clip_id or youtube_id(p.name) or _content_id(p)
140
+ return ClipFiles(video=p, clip_id=clip_id, source=str(p), **found)
141
+ from terpsi.config import videos_dir
142
+
143
+ dest = Path(dest) if dest else videos_dir()
144
+ dest.mkdir(parents=True, exist_ok=True)
145
+ downloader = downloader or yb_downloader
146
+ return downloader(str(source), dest, **opts)
147
+
148
+
149
+ def _content_id(path: Path) -> str:
150
+ """A stable id for a local file with no platform id: the sha256 prefix of its bytes."""
151
+ import hashlib
152
+
153
+ h = hashlib.sha256()
154
+ with open(path, "rb") as f:
155
+ for chunk in iter(lambda: f.read(1 << 20), b""):
156
+ h.update(chunk)
157
+ return "local_" + h.hexdigest()[:12]
158
+
159
+
160
+ def expand_playlist(url: str, *, n: int | None = None) -> list[str]:
161
+ """The video URLs of a YouTube playlist (flat extraction, nothing downloaded)."""
162
+ import yt_dlp
163
+
164
+ opts = {"quiet": True, "extract_flat": True, "no_warnings": True}
165
+ if n:
166
+ opts["playlistend"] = n
167
+ with yt_dlp.YoutubeDL(opts) as ydl:
168
+ info = ydl.extract_info(url, download=False)
169
+ return [
170
+ f"https://www.youtube.com/watch?v={e['id']}"
171
+ for e in (info.get("entries") or [])
172
+ if e.get("id")
173
+ ]
174
+
175
+
176
+ def is_playlist(source: str) -> bool:
177
+ """Whether ``source`` is a YouTube playlist URL (``list=`` without a video id)."""
178
+ return "list=" in str(source) and youtube_id(str(source)) is None
179
+
180
+
181
+ def search_youtube(
182
+ query: str,
183
+ *,
184
+ n: int = 20,
185
+ creative_commons: bool = True,
186
+ min_s: float = 30,
187
+ max_s: float = 900,
188
+ ) -> list[dict]:
189
+ """Candidate clips for ``query`` from YouTube search, as flat dicts.
190
+
191
+ With ``creative_commons`` (default) the search carries YouTube's own CC-licence
192
+ filter, so every hit is declared reusable by its uploader (yt-dlp's ``license``
193
+ field on the full info confirms it per clip). Durations outside ``[min_s, max_s]``
194
+ (shorts, full classes) are dropped.
195
+ """
196
+ from urllib.parse import quote_plus
197
+
198
+ import yt_dlp
199
+
200
+ url = f"https://www.youtube.com/results?search_query={quote_plus(query)}"
201
+ if creative_commons:
202
+ url += f"&sp={_CC_FILTER}"
203
+ opts = {"quiet": True, "extract_flat": True, "playlistend": n, "no_warnings": True}
204
+ with yt_dlp.YoutubeDL(opts) as ydl:
205
+ info = ydl.extract_info(url, download=False)
206
+ out = []
207
+ for e in info.get("entries") or []:
208
+ d = e.get("duration")
209
+ if d is None or not (min_s <= d <= max_s):
210
+ continue
211
+ out.append(
212
+ {
213
+ "id": e.get("id"),
214
+ "title": e.get("title"),
215
+ "duration": d,
216
+ "view_count": e.get("view_count"),
217
+ "channel": e.get("channel") or e.get("uploader"),
218
+ "url": f"https://www.youtube.com/watch?v={e.get('id')}",
219
+ "query": query,
220
+ }
221
+ )
222
+ return out