presenter-json 0.1.0__tar.gz

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.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: presenter-json
3
+ Version: 0.1.0
4
+ Summary: Pydantic models and JSON helpers for presentation timing format, shared across projects
5
+ License: MIT
6
+ Keywords: propresenter,presentation,json,pydantic,timing
7
+ Author: scaperoth
8
+ Author-email: scaperoth@berkeley.edu
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Classifier: Topic :: Multimedia :: Sound/Audio
19
+ Requires-Dist: pydantic (>=2.0,<3.0)
20
+ Project-URL: Homepage, https://github.com/scaperothian/presenter-json
21
+ Project-URL: Issues, https://github.com/scaperothian/presenter-json/issues
22
+ Project-URL: Repository, https://github.com/scaperothian/presenter-json
23
+ Description-Content-Type: text/markdown
24
+
25
+ # presenter-json
26
+
27
+ Pydantic models and JSON helpers for **presentation
28
+ timing format** — a shared library for the various projects that read and
29
+ generate these JSON files (training data, playback, benchmarking, …).
30
+
31
+ The JSON shape resembles the ProPresenter `/v1/presentation/{uuid}` API response,
32
+ with extra metadata on `presentation.id` (audio source, timing method, file
33
+ version) and per-slide timing lists (`trigger time`, `start time`, `stop time`).
34
+ Any ProPresenter field not explicitly modelled passes through untouched.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install presenter-json
40
+ ```
41
+
42
+ Or with Poetry, as a path/develop dependency from a sibling project:
43
+
44
+ ```toml
45
+ [tool.poetry.dependencies]
46
+ presenter-json = { path = "../presenter-json", develop = true }
47
+ ```
48
+
49
+ ## Reading a file
50
+
51
+ ```python
52
+ from presenter_json import load_presentation, cues, slide_texts
53
+
54
+ pres = load_presentation("sermon.json")
55
+
56
+ print(pres.presentation.id.name)
57
+ print(slide_texts(pres)) # text of every slide, flattened
58
+
59
+ for cue in cues(pres): # sorted playback cues
60
+ print(f"{cue.time:7.3f} [{cue.group_name}] {cue.text}")
61
+ ```
62
+
63
+ ## Generating a file
64
+
65
+ ```python
66
+ from presenter_json import (
67
+ new_presentation, add_group, add_slide,
68
+ set_trigger_times, set_metadata, save_presentation,
69
+ )
70
+
71
+ pres = new_presentation(name="My Song", audio_path="audio/my-song.wav")
72
+ set_metadata(pres, method="model", method_description="forced alignment", version="1.0")
73
+
74
+ verse = add_group(pres, "Verse 1")
75
+ s1 = add_slide(verse, "First line of the verse")
76
+ set_trigger_times(s1, [0.0])
77
+ add_slide(verse, "Second line") # untriggered — no timing written
78
+
79
+ save_presentation(pres, "my-song.json")
80
+ ```
81
+
82
+ ## API overview
83
+
84
+ **IO** (`presenter_json.io`)
85
+ - `load_presentation(path)` / `loads_presentation(text)` → `PresentationFile`
86
+ - `save_presentation(model, path)` / `dumps_presentation(model)`
87
+ - `load_raw(path)` → unvalidated `dict`
88
+
89
+ **Reading** (`presenter_json.helpers`)
90
+ - `iter_groups`, `iter_slides`, `group_name`, `slide_text`, `slide_texts`, `slide_field`
91
+ - `get_trigger_times` / `get_start_times` / `get_stop_times` / `get_timing`
92
+ - `detect_timing_key`, `cues` (sorted `Cue` list)
93
+
94
+ **Generating / mutating**
95
+ - `new_presentation`, `add_group`, `add_slide`
96
+ - `set_trigger_times` / `set_start_times` / `set_stop_times` / `set_timing`, `clear_timing`
97
+ - `set_metadata`, `from_api_response`
98
+
99
+ **Models** (`presenter_json.models`)
100
+ - `PresentationFile`, `Presentation`, `PresentationId`, `Group`, `Slide`
101
+ - Constants: `METHOD_MANUAL`, `METHOD_CAPTIONS`, `METHOD_MODEL`, `TRIGGER_TIME_KEY`, …
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ poetry install
107
+ poetry run pytest
108
+ ```
109
+
110
+ Tests run in GitHub Actions across Python 3.11–3.13 (`.github/workflows/ci.yml`).
111
+ Tagging a release (`vX.Y.Z`) publishes to PyPI via Trusted Publishing
112
+ (`.github/workflows/publish.yml`).
113
+
114
+ All changes go through a branch off `main` and land via pull request.
115
+
@@ -0,0 +1,90 @@
1
+ # presenter-json
2
+
3
+ Pydantic models and JSON helpers for **presentation
4
+ timing format** — a shared library for the various projects that read and
5
+ generate these JSON files (training data, playback, benchmarking, …).
6
+
7
+ The JSON shape resembles the ProPresenter `/v1/presentation/{uuid}` API response,
8
+ with extra metadata on `presentation.id` (audio source, timing method, file
9
+ version) and per-slide timing lists (`trigger time`, `start time`, `stop time`).
10
+ Any ProPresenter field not explicitly modelled passes through untouched.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install presenter-json
16
+ ```
17
+
18
+ Or with Poetry, as a path/develop dependency from a sibling project:
19
+
20
+ ```toml
21
+ [tool.poetry.dependencies]
22
+ presenter-json = { path = "../presenter-json", develop = true }
23
+ ```
24
+
25
+ ## Reading a file
26
+
27
+ ```python
28
+ from presenter_json import load_presentation, cues, slide_texts
29
+
30
+ pres = load_presentation("sermon.json")
31
+
32
+ print(pres.presentation.id.name)
33
+ print(slide_texts(pres)) # text of every slide, flattened
34
+
35
+ for cue in cues(pres): # sorted playback cues
36
+ print(f"{cue.time:7.3f} [{cue.group_name}] {cue.text}")
37
+ ```
38
+
39
+ ## Generating a file
40
+
41
+ ```python
42
+ from presenter_json import (
43
+ new_presentation, add_group, add_slide,
44
+ set_trigger_times, set_metadata, save_presentation,
45
+ )
46
+
47
+ pres = new_presentation(name="My Song", audio_path="audio/my-song.wav")
48
+ set_metadata(pres, method="model", method_description="forced alignment", version="1.0")
49
+
50
+ verse = add_group(pres, "Verse 1")
51
+ s1 = add_slide(verse, "First line of the verse")
52
+ set_trigger_times(s1, [0.0])
53
+ add_slide(verse, "Second line") # untriggered — no timing written
54
+
55
+ save_presentation(pres, "my-song.json")
56
+ ```
57
+
58
+ ## API overview
59
+
60
+ **IO** (`presenter_json.io`)
61
+ - `load_presentation(path)` / `loads_presentation(text)` → `PresentationFile`
62
+ - `save_presentation(model, path)` / `dumps_presentation(model)`
63
+ - `load_raw(path)` → unvalidated `dict`
64
+
65
+ **Reading** (`presenter_json.helpers`)
66
+ - `iter_groups`, `iter_slides`, `group_name`, `slide_text`, `slide_texts`, `slide_field`
67
+ - `get_trigger_times` / `get_start_times` / `get_stop_times` / `get_timing`
68
+ - `detect_timing_key`, `cues` (sorted `Cue` list)
69
+
70
+ **Generating / mutating**
71
+ - `new_presentation`, `add_group`, `add_slide`
72
+ - `set_trigger_times` / `set_start_times` / `set_stop_times` / `set_timing`, `clear_timing`
73
+ - `set_metadata`, `from_api_response`
74
+
75
+ **Models** (`presenter_json.models`)
76
+ - `PresentationFile`, `Presentation`, `PresentationId`, `Group`, `Slide`
77
+ - Constants: `METHOD_MANUAL`, `METHOD_CAPTIONS`, `METHOD_MODEL`, `TRIGGER_TIME_KEY`, …
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ poetry install
83
+ poetry run pytest
84
+ ```
85
+
86
+ Tests run in GitHub Actions across Python 3.11–3.13 (`.github/workflows/ci.yml`).
87
+ Tagging a release (`vX.Y.Z`) publishes to PyPI via Trusted Publishing
88
+ (`.github/workflows/publish.yml`).
89
+
90
+ All changes go through a branch off `main` and land via pull request.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "presenter-json"
3
+ version = "0.1.0"
4
+ authors = [{ name = "scaperoth", email = "scaperoth@berkeley.edu" }]
5
+ description = "Pydantic models and JSON helpers for presentation timing format, shared across projects"
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ requires-python = ">=3.11,<4.0"
9
+ keywords = ["propresenter", "presentation", "json", "pydantic", "timing"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries",
19
+ "Topic :: Multimedia :: Sound/Audio",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.0,<3.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/scaperothian/presenter-json"
27
+ Repository = "https://github.com/scaperothian/presenter-json"
28
+ Issues = "https://github.com/scaperothian/presenter-json/issues"
29
+
30
+ [tool.poetry]
31
+ packages = [{ include = "presenter_json", from = "src" }]
32
+
33
+ [tool.poetry.group.dev.dependencies]
34
+ pytest = ">=7.0"
35
+
36
+ [build-system]
37
+ requires = ["poetry-core>=2.0"]
38
+ build-backend = "poetry.core.masonry.api"
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = "-ra"
@@ -0,0 +1,111 @@
1
+ """presenter-json — Pydantic models and JSON helpers for the ProPresenter
2
+ gold-copy presentation timing format.
3
+
4
+ Typical use::
5
+
6
+ from presenter_json import load_presentation, cues, slide_texts
7
+
8
+ pres = load_presentation("sermon.json")
9
+ for cue in cues(pres):
10
+ print(cue.time, cue.text)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .helpers import (
16
+ Cue,
17
+ add_group,
18
+ add_slide,
19
+ clear_timing,
20
+ cues,
21
+ detect_timing_key,
22
+ from_api_response,
23
+ get_start_times,
24
+ get_stop_times,
25
+ get_timing,
26
+ get_trigger_times,
27
+ group_name,
28
+ iter_groups,
29
+ iter_slides,
30
+ new_presentation,
31
+ set_metadata,
32
+ set_start_times,
33
+ set_stop_times,
34
+ set_timing,
35
+ set_trigger_times,
36
+ slide_field,
37
+ slide_text,
38
+ slide_texts,
39
+ )
40
+ from .io import (
41
+ dumps_presentation,
42
+ load_presentation,
43
+ load_raw,
44
+ loads_presentation,
45
+ save_presentation,
46
+ )
47
+ from .models import (
48
+ METHOD_CAPTIONS,
49
+ METHOD_MANUAL,
50
+ METHOD_MODEL,
51
+ METHODS,
52
+ START_TIME_KEY,
53
+ STOP_TIME_KEY,
54
+ TIMING_KEYS,
55
+ TRIGGER_TIME_KEY,
56
+ Group,
57
+ Presentation,
58
+ PresentationFile,
59
+ PresentationId,
60
+ Slide,
61
+ )
62
+
63
+ __version__ = "0.1.0"
64
+
65
+ __all__ = [
66
+ "__version__",
67
+ # models
68
+ "PresentationFile",
69
+ "Presentation",
70
+ "PresentationId",
71
+ "Group",
72
+ "Slide",
73
+ "METHOD_MANUAL",
74
+ "METHOD_CAPTIONS",
75
+ "METHOD_MODEL",
76
+ "METHODS",
77
+ "TRIGGER_TIME_KEY",
78
+ "START_TIME_KEY",
79
+ "STOP_TIME_KEY",
80
+ "TIMING_KEYS",
81
+ # io
82
+ "load_presentation",
83
+ "loads_presentation",
84
+ "save_presentation",
85
+ "dumps_presentation",
86
+ "load_raw",
87
+ # helpers
88
+ "Cue",
89
+ "iter_groups",
90
+ "iter_slides",
91
+ "group_name",
92
+ "slide_field",
93
+ "slide_text",
94
+ "slide_texts",
95
+ "get_timing",
96
+ "get_trigger_times",
97
+ "get_start_times",
98
+ "get_stop_times",
99
+ "detect_timing_key",
100
+ "cues",
101
+ "new_presentation",
102
+ "add_group",
103
+ "add_slide",
104
+ "set_timing",
105
+ "set_trigger_times",
106
+ "set_start_times",
107
+ "set_stop_times",
108
+ "clear_timing",
109
+ "set_metadata",
110
+ "from_api_response",
111
+ ]
@@ -0,0 +1,257 @@
1
+ """JSON-centric helpers for reading and generating presenter JSON.
2
+
3
+ Two groups of functions:
4
+
5
+ * **Reading** — flatten a :class:`PresentationFile` into groups, slides, slide
6
+ text, timing lists, and sorted playback cues.
7
+ * **Generating / mutating** — build a presentation from scratch, add groups and
8
+ slides, stamp metadata, and set or clear a slide's timing lists.
9
+
10
+ Slide content (text, enabled, notes, …) and timing keys all live in a slide's
11
+ ``model_extra`` because :class:`~presenter_json.models.Slide` declares no fields.
12
+ These helpers hide that detail behind named accessors.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Iterable, Iterator, NamedTuple
18
+
19
+ from .models import (
20
+ START_TIME_KEY,
21
+ STOP_TIME_KEY,
22
+ TIMING_KEYS,
23
+ TRIGGER_TIME_KEY,
24
+ Group,
25
+ Presentation,
26
+ PresentationFile,
27
+ PresentationId,
28
+ Slide,
29
+ )
30
+
31
+ __all__ = [
32
+ "Cue",
33
+ "iter_groups",
34
+ "iter_slides",
35
+ "group_name",
36
+ "slide_field",
37
+ "slide_text",
38
+ "slide_texts",
39
+ "get_timing",
40
+ "get_trigger_times",
41
+ "get_start_times",
42
+ "get_stop_times",
43
+ "detect_timing_key",
44
+ "cues",
45
+ "new_presentation",
46
+ "add_group",
47
+ "add_slide",
48
+ "set_timing",
49
+ "set_trigger_times",
50
+ "set_start_times",
51
+ "set_stop_times",
52
+ "clear_timing",
53
+ "set_metadata",
54
+ "from_api_response",
55
+ ]
56
+
57
+
58
+ class Cue(NamedTuple):
59
+ """A single playback cue: fire ``slide`` at ``time`` seconds."""
60
+
61
+ time: float
62
+ slide_index: int # 0-based index into the flattened slide list
63
+ group_index: int # 0-based index of the containing group
64
+ group_name: str
65
+ text: str
66
+ slide: Slide
67
+
68
+
69
+ # --------------------------------------------------------------------------- #
70
+ # Reading
71
+ # --------------------------------------------------------------------------- #
72
+
73
+ def _extras(model: Any) -> dict[str, Any]:
74
+ """Return a model's mutable extras dict, tolerating ``None``."""
75
+ extra = getattr(model, "model_extra", None)
76
+ return extra if extra is not None else {}
77
+
78
+
79
+ def iter_groups(model: PresentationFile) -> Iterator[Group]:
80
+ """Yield every :class:`Group` in the presentation, in order."""
81
+ yield from model.presentation.groups
82
+
83
+
84
+ def iter_slides(model: PresentationFile) -> Iterator[Slide]:
85
+ """Yield every :class:`Slide`, flattened across all groups, in order."""
86
+ for group in model.presentation.groups:
87
+ yield from group.slides
88
+
89
+
90
+ def group_name(group: Group) -> str:
91
+ """Return a group's ``name`` (empty string if unset)."""
92
+ return _extras(group).get("name", "") or ""
93
+
94
+
95
+ def slide_field(slide: Slide, key: str, default: Any = None) -> Any:
96
+ """Return an arbitrary field from a slide's extras."""
97
+ return _extras(slide).get(key, default)
98
+
99
+
100
+ def slide_text(slide: Slide) -> str:
101
+ """Return a slide's ``text`` (empty string if unset)."""
102
+ return _extras(slide).get("text", "") or ""
103
+
104
+
105
+ def slide_texts(model: PresentationFile) -> list[str]:
106
+ """Return the ``text`` of every slide, flattened across groups."""
107
+ return [slide_text(s) for s in iter_slides(model)]
108
+
109
+
110
+ def get_timing(slide: Slide, key: str) -> list[float]:
111
+ """Return the timing list stored under ``key`` (empty list if absent)."""
112
+ values = _extras(slide).get(key)
113
+ if not isinstance(values, list):
114
+ return []
115
+ return [float(t) for t in values if isinstance(t, (int, float))]
116
+
117
+
118
+ def get_trigger_times(slide: Slide) -> list[float]:
119
+ return get_timing(slide, TRIGGER_TIME_KEY)
120
+
121
+
122
+ def get_start_times(slide: Slide) -> list[float]:
123
+ return get_timing(slide, START_TIME_KEY)
124
+
125
+
126
+ def get_stop_times(slide: Slide) -> list[float]:
127
+ return get_timing(slide, STOP_TIME_KEY)
128
+
129
+
130
+ def detect_timing_key(model: PresentationFile) -> str | None:
131
+ """Return the timing key in use across the presentation, or ``None``.
132
+
133
+ Prefers ``trigger time``; falls back to ``start time`` then ``stop time``.
134
+ A key is "in use" if any slide carries a non-empty list for it.
135
+ """
136
+ slides = list(iter_slides(model))
137
+ for key in TIMING_KEYS:
138
+ if any(get_timing(s, key) for s in slides):
139
+ return key
140
+ return None
141
+
142
+
143
+ def cues(model: PresentationFile, *, timing_key: str | None = None) -> list[Cue]:
144
+ """Return every timing cue, sorted by time.
145
+
146
+ ``timing_key`` defaults to :func:`detect_timing_key`. Returns an empty list
147
+ if the presentation carries no timing data.
148
+ """
149
+ if timing_key is None:
150
+ timing_key = detect_timing_key(model)
151
+ if timing_key is None:
152
+ return []
153
+
154
+ result: list[Cue] = []
155
+ slide_index = 0
156
+ for group_index, group in enumerate(iter_groups(model)):
157
+ gname = group_name(group)
158
+ for slide in group.slides:
159
+ for t in get_timing(slide, timing_key):
160
+ result.append(
161
+ Cue(
162
+ time=t,
163
+ slide_index=slide_index,
164
+ group_index=group_index,
165
+ group_name=gname,
166
+ text=slide_text(slide),
167
+ slide=slide,
168
+ )
169
+ )
170
+ slide_index += 1
171
+
172
+ result.sort(key=lambda c: c.time)
173
+ return result
174
+
175
+
176
+ # --------------------------------------------------------------------------- #
177
+ # Generating / mutating
178
+ # --------------------------------------------------------------------------- #
179
+
180
+ def new_presentation(
181
+ *,
182
+ name: str | None = None,
183
+ uuid: str | None = None,
184
+ index: int | None = None,
185
+ **id_fields: Any,
186
+ ) -> PresentationFile:
187
+ """Build an empty :class:`PresentationFile` with metadata on ``presentation.id``.
188
+
189
+ Extra keyword arguments (e.g. ``audio_path``, ``method``) are set on the
190
+ :class:`PresentationId`.
191
+ """
192
+ pid = PresentationId(name=name, uuid=uuid, index=index, **id_fields)
193
+ return PresentationFile(presentation=Presentation(id=pid))
194
+
195
+
196
+ def add_group(model: PresentationFile, name: str = "", **fields: Any) -> Group:
197
+ """Append a new :class:`Group` to the presentation and return it."""
198
+ group = Group(**({"name": name, **fields} if name or fields else {}))
199
+ model.presentation.groups.append(group)
200
+ return group
201
+
202
+
203
+ def add_slide(group: Group, text: str = "", **fields: Any) -> Slide:
204
+ """Append a new :class:`Slide` to ``group`` and return it.
205
+
206
+ ``text`` and any extra keyword fields are stored on the slide.
207
+ """
208
+ data: dict[str, Any] = {}
209
+ if text:
210
+ data["text"] = text
211
+ data.update(fields)
212
+ slide = Slide(**data)
213
+ group.slides.append(slide)
214
+ return slide
215
+
216
+
217
+ def set_timing(slide: Slide, key: str, times: Iterable[float]) -> None:
218
+ """Set the timing list under ``key`` on ``slide``."""
219
+ setattr(slide, key, [float(t) for t in times])
220
+
221
+
222
+ def set_trigger_times(slide: Slide, times: Iterable[float]) -> None:
223
+ set_timing(slide, TRIGGER_TIME_KEY, times)
224
+
225
+
226
+ def set_start_times(slide: Slide, times: Iterable[float]) -> None:
227
+ set_timing(slide, START_TIME_KEY, times)
228
+
229
+
230
+ def set_stop_times(slide: Slide, times: Iterable[float]) -> None:
231
+ set_timing(slide, STOP_TIME_KEY, times)
232
+
233
+
234
+ def clear_timing(slide: Slide, keys: Iterable[str] = TIMING_KEYS) -> None:
235
+ """Remove timing keys from a slide so they are omitted from output."""
236
+ extra = _extras(slide)
237
+ for key in keys:
238
+ extra.pop(key, None)
239
+
240
+
241
+ def set_metadata(model: PresentationFile, **fields: Any) -> PresentationId:
242
+ """Set fields on ``presentation.id`` (e.g. ``audio_path``, ``method``, ``version``)."""
243
+ pid = model.presentation.id
244
+ for key, value in fields.items():
245
+ setattr(pid, key, value)
246
+ return pid
247
+
248
+
249
+ def from_api_response(data: dict) -> PresentationFile:
250
+ """Wrap a raw ProPresenter ``/v1/presentation/{uuid}`` payload.
251
+
252
+ Accepts either the full ``{"presentation": {...}}`` envelope or a bare
253
+ ``presentation`` object and returns a validated :class:`PresentationFile`.
254
+ """
255
+ if "presentation" in data:
256
+ return PresentationFile.model_validate(data)
257
+ return PresentationFile.model_validate({"presentation": data})
@@ -0,0 +1,63 @@
1
+ """Read and write presenter JSON files.
2
+
3
+ These are thin, JSON-centric wrappers around the pydantic models in
4
+ :mod:`presenter_json.models`. They accept ``str`` or :class:`os.PathLike`
5
+ paths and always round-trip through :class:`~presenter_json.models.PresentationFile`
6
+ so extra/pass-through fields are preserved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from os import PathLike
13
+ from pathlib import Path
14
+
15
+ from .models import PresentationFile
16
+
17
+ __all__ = [
18
+ "load_presentation",
19
+ "loads_presentation",
20
+ "save_presentation",
21
+ "dumps_presentation",
22
+ "load_raw",
23
+ ]
24
+
25
+ StrPath = str | PathLike[str]
26
+
27
+
28
+ def load_presentation(path: StrPath) -> PresentationFile:
29
+ """Load and validate a presenter JSON file into a :class:`PresentationFile`."""
30
+ text = Path(path).read_text(encoding="utf-8")
31
+ return loads_presentation(text)
32
+
33
+
34
+ def loads_presentation(text: str) -> PresentationFile:
35
+ """Validate a JSON string into a :class:`PresentationFile`."""
36
+ return PresentationFile.model_validate_json(text)
37
+
38
+
39
+ def dumps_presentation(model: PresentationFile, *, indent: int | None = 2) -> str:
40
+ """Serialize a :class:`PresentationFile` to a JSON string."""
41
+ return model.model_dump_json(indent=indent)
42
+
43
+
44
+ def save_presentation(
45
+ model: PresentationFile, path: StrPath, *, indent: int | None = 2
46
+ ) -> Path:
47
+ """Write ``model`` to ``path`` as JSON and return the resolved path.
48
+
49
+ Parent directories are created if they do not exist.
50
+ """
51
+ out = Path(path)
52
+ out.parent.mkdir(parents=True, exist_ok=True)
53
+ out.write_text(dumps_presentation(model, indent=indent), encoding="utf-8")
54
+ return out
55
+
56
+
57
+ def load_raw(path: StrPath) -> dict:
58
+ """Load a presenter JSON file as a plain ``dict`` without validation.
59
+
60
+ Useful when you need the untouched ProPresenter payload (e.g. to inspect
61
+ fields that are not modelled) rather than a :class:`PresentationFile`.
62
+ """
63
+ return json.loads(Path(path).read_text(encoding="utf-8"))
@@ -0,0 +1,87 @@
1
+ """
2
+ Pydantic models for the ProPresenter gold-copy presentation JSON format.
3
+
4
+ The top-level shape mirrors the ``/v1/presentation/{uuid}`` ProPresenter API
5
+ response, with extra keys added to ``presentation.id`` describing the audio, the
6
+ timing method, and file versioning.
7
+
8
+ All ProPresenter API fields not explicitly modelled here pass through via
9
+ ``extra="allow"`` so the JSON faithfully reflects the original API response.
10
+ Timing fields (trigger time, start time, stop time) are stored as extras on
11
+ :class:`Slide` so they are omitted entirely from slides that were never
12
+ triggered.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ METHOD_MANUAL = "manual"
20
+ METHOD_CAPTIONS = "captions"
21
+ METHOD_MODEL = "model"
22
+
23
+ #: Valid values for :attr:`PresentationId.method`.
24
+ METHODS = (METHOD_MANUAL, METHOD_CAPTIONS, METHOD_MODEL)
25
+
26
+ #: Timing keys stored as extras on a :class:`Slide`. Each maps to a list of
27
+ #: floats (seconds). ``trigger time`` is the primary cue list.
28
+ TRIGGER_TIME_KEY = "trigger time"
29
+ START_TIME_KEY = "start time"
30
+ STOP_TIME_KEY = "stop time"
31
+ TIMING_KEYS = (TRIGGER_TIME_KEY, START_TIME_KEY, STOP_TIME_KEY)
32
+
33
+
34
+ class PresentationId(BaseModel):
35
+ model_config = ConfigDict(extra="allow")
36
+
37
+ uuid: str | None = None
38
+ name: str | None = None
39
+ index: int | None = None
40
+
41
+ # Audio the timing is against.
42
+ audio_path: str = "" # local path to the audio file
43
+ audio_description: str = "" # freeform: where the audio came from
44
+ audio_url: str = "" # optional: internet source URL for the audio
45
+
46
+ # How the trigger times were produced.
47
+ method: str = METHOD_MANUAL # manual | captions | model
48
+ method_description: str = "" # freeform: model name / "forced alignment" / ...
49
+ method_url: str = "" # URL of the software implementing the method
50
+ method_version: str = "" # version of that software
51
+
52
+ # Versioning of THIS json file itself — bumped on micro edits (e.g. a human
53
+ # nudging one trigger time), independent of the generating software.
54
+ version: str = ""
55
+ comment: str = "" # optional freeform note about a change/edit
56
+
57
+
58
+ class Slide(BaseModel):
59
+ """A single ProPresenter slide.
60
+
61
+ All fields (enabled, notes, text, label, …) pass through as extras. Timing
62
+ keys are written directly into ``model_extra`` so they appear in the output
63
+ only for slides that were actually triggered.
64
+ """
65
+
66
+ model_config = ConfigDict(extra="allow")
67
+
68
+
69
+ class Group(BaseModel):
70
+ model_config = ConfigDict(extra="allow")
71
+
72
+ slides: list[Slide] = Field(default_factory=list)
73
+
74
+
75
+ class Presentation(BaseModel):
76
+ model_config = ConfigDict(extra="allow")
77
+
78
+ id: PresentationId = Field(default_factory=PresentationId)
79
+ groups: list[Group] = Field(default_factory=list)
80
+
81
+
82
+ class PresentationFile(BaseModel):
83
+ """Root model — the object serialized to and from a ``*.json`` file."""
84
+
85
+ model_config = ConfigDict(extra="allow")
86
+
87
+ presentation: Presentation
File without changes