presenter-json 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.
- presenter_json/__init__.py +111 -0
- presenter_json/helpers.py +257 -0
- presenter_json/io.py +63 -0
- presenter_json/models.py +87 -0
- presenter_json/py.typed +0 -0
- presenter_json-0.1.0.dist-info/METADATA +115 -0
- presenter_json-0.1.0.dist-info/RECORD +8 -0
- presenter_json-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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})
|
presenter_json/io.py
ADDED
|
@@ -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"))
|
presenter_json/models.py
ADDED
|
@@ -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
|
presenter_json/py.typed
ADDED
|
File without changes
|
|
@@ -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,8 @@
|
|
|
1
|
+
presenter_json/__init__.py,sha256=bmZRzZP3gsrtr1cZdZLdxc3E77Iq_9_f0rPBNKuXgG4,2093
|
|
2
|
+
presenter_json/helpers.py,sha256=k1ibpJstkq0D2b6IbqRClwsQjP0nMV5ORm9dorNXHR8,8007
|
|
3
|
+
presenter_json/io.py,sha256=7qFISEpJNJ5UYNCMvtuMUdI0l3Fh6EtSAAlnIm84D3I,1969
|
|
4
|
+
presenter_json/models.py,sha256=t3Khw1kI6IaPLwwpuZ8E5_fIigROjeCiuuxG2Nr3t2Y,3039
|
|
5
|
+
presenter_json/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
presenter_json-0.1.0.dist-info/METADATA,sha256=6F6vhgF6xOPZ-dBxKovjzz-8btdHE5M06k8_QQZMObs,3946
|
|
7
|
+
presenter_json-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
8
|
+
presenter_json-0.1.0.dist-info/RECORD,,
|