truedemo 2.0.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.
- truedemo/__init__.py +24 -0
- truedemo/__main__.py +6 -0
- truedemo/_loop.py +192 -0
- truedemo/_version.py +9 -0
- truedemo/assembler.py +1223 -0
- truedemo/capture.py +653 -0
- truedemo/card.py +132 -0
- truedemo/cli.py +450 -0
- truedemo/context.py +193 -0
- truedemo/errors.py +19 -0
- truedemo/exec.py +1420 -0
- truedemo/ffmpeg.py +666 -0
- truedemo/job.py +609 -0
- truedemo/mcp_server.py +1786 -0
- truedemo/narration.py +176 -0
- truedemo/overlay.py +75 -0
- truedemo/panels.py +417 -0
- truedemo/provenance.py +228 -0
- truedemo/provider.py +185 -0
- truedemo/py.typed +0 -0
- truedemo/reports.py +501 -0
- truedemo/scaffold.py +212 -0
- truedemo/secrets.py +161 -0
- truedemo/snapshot.py +159 -0
- truedemo/spec.py +1476 -0
- truedemo/studio.py +1503 -0
- truedemo/svg.py +150 -0
- truedemo/terminal.py +415 -0
- truedemo/timeframe.py +468 -0
- truedemo/tts.py +119 -0
- truedemo/verify.py +564 -0
- truedemo-2.0.0.dist-info/METADATA +315 -0
- truedemo-2.0.0.dist-info/RECORD +37 -0
- truedemo-2.0.0.dist-info/WHEEL +5 -0
- truedemo-2.0.0.dist-info/entry_points.txt +3 -0
- truedemo-2.0.0.dist-info/licenses/LICENSE +204 -0
- truedemo-2.0.0.dist-info/top_level.txt +1 -0
truedemo/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""truedemo — a declarative toolkit for producing narrated, captioned screen-recordings.
|
|
2
|
+
|
|
3
|
+
truedemo turns a small YAML *spec* plus a data provider into:
|
|
4
|
+
|
|
5
|
+
* per-scene narration audio (TTS),
|
|
6
|
+
* real or captured still frames (screen recordings via Playwright, charts from data),
|
|
7
|
+
* a single captioned ``.mp4`` with burned-in subtitles, and
|
|
8
|
+
* a verification report that checks the non-negotiables (runtime window, banned
|
|
9
|
+
phrases, missing assets, mock-mode leakage).
|
|
10
|
+
|
|
11
|
+
It is deliberately dependency-light: ``ffmpeg`` and ``rsvg-convert`` for rendering,
|
|
12
|
+
``playwright`` (optional) for capture, ``piper-tts`` (optional) for speech, and
|
|
13
|
+
``pyyaml`` (optional — JSON specs work without it).
|
|
14
|
+
|
|
15
|
+
The worked example lives in ``../examples`` and is host-coupled by design; the
|
|
16
|
+
library itself contains no product knowledge.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ._version import __version__ # noqa: F401 (re-exported)
|
|
22
|
+
from .errors import SpecError, ToolError, TruedemoError # noqa: F401
|
|
23
|
+
|
|
24
|
+
__all__ = ["TruedemoError", "SpecError", "ToolError", "__version__"]
|
truedemo/__main__.py
ADDED
truedemo/_loop.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Doing the engine's blocking work without stopping the server that asked for it.
|
|
2
|
+
|
|
3
|
+
The engine is synchronous, deliberately. A capture is Playwright's *sync* API, a
|
|
4
|
+
render is ``subprocess.run`` over ffmpeg, a container probe is a blocking read,
|
|
5
|
+
and a session is a long-lived object graph held in memory. Rewriting any of it to
|
|
6
|
+
be awaitable, so that the MCP layer could call it, would mean a second
|
|
7
|
+
implementation of everything — and the second one is the one nobody tests.
|
|
8
|
+
|
|
9
|
+
An MCP tool, though, is invoked by FastMCP **on its event loop**. Two things
|
|
10
|
+
cannot happen there, and both of them did:
|
|
11
|
+
|
|
12
|
+
* Playwright's sync API refuses outright —
|
|
13
|
+
``It looks like you are using Playwright Sync API inside the asyncio loop.`` —
|
|
14
|
+
so every browser tool failed always, with a message naming Playwright rather
|
|
15
|
+
than the seam.
|
|
16
|
+
* Anything merely blocking stopped the whole server for its duration: every other
|
|
17
|
+
client, every keepalive, for as long as an ffmpeg render takes.
|
|
18
|
+
|
|
19
|
+
So there are two jobs here, and they are different jobs.
|
|
20
|
+
|
|
21
|
+
**A session-bound worker thread.** Playwright's sync objects are bound to the
|
|
22
|
+
thread that created them: the thread that opened a page has to be the thread that
|
|
23
|
+
later clicks it. A pool would work by luck and break under load, so each session
|
|
24
|
+
owns one thread, started lazily on first use.
|
|
25
|
+
|
|
26
|
+
**A plain off-loop hop.** Work with no thread affinity — a render, a docker probe,
|
|
27
|
+
a report read — only needs to not happen on the loop.
|
|
28
|
+
|
|
29
|
+
Both fast-path through when there is no loop, which is how the CLI, the pipeline's
|
|
30
|
+
own stages and the tests keep calling the engine directly with no thread at all.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import contextvars
|
|
37
|
+
import queue
|
|
38
|
+
import threading
|
|
39
|
+
from concurrent.futures import Future
|
|
40
|
+
from typing import Any, Callable, TypeVar
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
#: How long to wait for a worker thread to finish when a session closes. A render
|
|
45
|
+
#: already in flight can outlast this; the caller is then told, rather than hung.
|
|
46
|
+
JOIN_TIMEOUT = 10.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Worker:
|
|
50
|
+
"""One thread for one session, and whatever blocking work belongs to it.
|
|
51
|
+
|
|
52
|
+
Jobs arrive on a :class:`queue.Queue`, which is thread-safe from either side,
|
|
53
|
+
so a caller may be an event loop or an ordinary stack. Results come back as
|
|
54
|
+
:class:`concurrent.futures.Future`, likewise safe to wait on from anywhere —
|
|
55
|
+
deliberately *not* an asyncio future, because one worker thread has to be able
|
|
56
|
+
to serve a client that runs no loop at all.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, name: str = "truedemo-worker") -> None:
|
|
60
|
+
self._name = name
|
|
61
|
+
self._thread: threading.Thread | None = None
|
|
62
|
+
self._jobs: "queue.Queue[Any] | None" = None
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def name(self) -> str:
|
|
67
|
+
"""The thread's name, which is how a hung worker is identified."""
|
|
68
|
+
return self._name
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def alive(self) -> bool:
|
|
72
|
+
t = self._thread
|
|
73
|
+
return t is not None and t.is_alive()
|
|
74
|
+
|
|
75
|
+
def submit(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> "Future[T]":
|
|
76
|
+
"""Queue ``fn(*args, **kwargs)`` and return a future for its result.
|
|
77
|
+
|
|
78
|
+
The callable runs on the worker thread and nowhere else, which is what
|
|
79
|
+
makes a page opened by one call clickable by the next.
|
|
80
|
+
"""
|
|
81
|
+
fut: "Future[T]" = Future()
|
|
82
|
+
with self._lock:
|
|
83
|
+
if not self.alive:
|
|
84
|
+
self._jobs = queue.Queue()
|
|
85
|
+
self._thread = threading.Thread(
|
|
86
|
+
target=self._serve, name=self._name, daemon=True)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
jobs = self._jobs
|
|
89
|
+
assert jobs is not None
|
|
90
|
+
jobs.put((lambda: fn(*args, **kwargs), fut))
|
|
91
|
+
return fut
|
|
92
|
+
|
|
93
|
+
def _serve(self) -> None:
|
|
94
|
+
"""The worker's whole life: one job, then the next, until the sentinel."""
|
|
95
|
+
jobs = self._jobs
|
|
96
|
+
assert jobs is not None
|
|
97
|
+
while True:
|
|
98
|
+
item = jobs.get()
|
|
99
|
+
if item is None:
|
|
100
|
+
return
|
|
101
|
+
fn, fut = item
|
|
102
|
+
if not fut.set_running_or_notify_cancel():
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
value = fn()
|
|
106
|
+
except BaseException as exc: # noqa: BLE001 - carried to the caller
|
|
107
|
+
fut.set_exception(exc)
|
|
108
|
+
else:
|
|
109
|
+
fut.set_result(value)
|
|
110
|
+
|
|
111
|
+
def stop(self) -> bool:
|
|
112
|
+
"""Ask the thread to finish, and wait for it. Safe to call twice."""
|
|
113
|
+
with self._lock:
|
|
114
|
+
thread, jobs = self._thread, self._jobs
|
|
115
|
+
self._thread, self._jobs = None, None
|
|
116
|
+
if thread is None or not thread.is_alive():
|
|
117
|
+
return False
|
|
118
|
+
if jobs is not None:
|
|
119
|
+
jobs.put(None)
|
|
120
|
+
if threading.current_thread() is thread:
|
|
121
|
+
# Called *from* the worker — a session closing itself. Joining here
|
|
122
|
+
# would be the thread waiting for its own death; the sentinel is
|
|
123
|
+
# already queued, so it exits as soon as this job returns.
|
|
124
|
+
return True
|
|
125
|
+
thread.join(timeout=JOIN_TIMEOUT)
|
|
126
|
+
return not thread.is_alive()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
# crossing the boundary
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
# There is no way to escape the loop *from* the loop: a synchronous function that
|
|
133
|
+
# blocks the loop thread has stopped the very thing that would run the work it is
|
|
134
|
+
# waiting for. So the direction of the fix is forced, and it is worth saying out
|
|
135
|
+
# loud, because the alternative looks reasonable and deadlocks under load.
|
|
136
|
+
#
|
|
137
|
+
# * A tool the protocol can reach is declared ``async`` and hops *out* with
|
|
138
|
+
# :func:`offload`. That is the only door an MCP client has.
|
|
139
|
+
# * A function that drives a session's browser runs on that session's own thread
|
|
140
|
+
# with :func:`session`, and does not care whether its caller was a loop, an
|
|
141
|
+
# anyio worker or a plain stack.
|
|
142
|
+
# * Code with no loop at all — the CLI, the pipeline's stages, the tests — calls
|
|
143
|
+
# the engine directly, and every one of these fast-paths through to a plain
|
|
144
|
+
# call.
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
def in_loop() -> bool:
|
|
147
|
+
"""Whether this call is already inside a running event loop."""
|
|
148
|
+
try:
|
|
149
|
+
asyncio.get_running_loop()
|
|
150
|
+
except RuntimeError:
|
|
151
|
+
return False
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def offload(fn: Callable[..., T], /, *args: Any, **kwargs: Any) -> T:
|
|
156
|
+
"""Await a blocking ``fn(*args, **kwargs)``, run on a thread that is not the loop.
|
|
157
|
+
|
|
158
|
+
What every registered MCP tool body should be. It has no thread affinity of its
|
|
159
|
+
own — a render, a container probe, a report read — and only needs to not happen
|
|
160
|
+
on the loop.
|
|
161
|
+
|
|
162
|
+
Deliberately built only on :mod:`asyncio` and :mod:`contextvars`, with no
|
|
163
|
+
third-party thread pool. An earlier version delegated to ``anyio.to_thread``,
|
|
164
|
+
which is *not* one of truedemo's declared dependencies — it merely arrives
|
|
165
|
+
alongside the optional ``mcp`` extra. So the seam held exactly as long as every
|
|
166
|
+
caller also installed the MCP server, and any caller with the engine alone (a
|
|
167
|
+
lean test run, a host mounting ``truedemo://`` by hand) got
|
|
168
|
+
``RuntimeError: ... anyio is not installed`` from a function documented as a
|
|
169
|
+
plain hop off the loop. A seam this central should not have a dependency the
|
|
170
|
+
engine does not declare, and it does not need a thread *pool*: the work here is
|
|
171
|
+
long and single, so one fresh thread per hop is the honest shape.
|
|
172
|
+
"""
|
|
173
|
+
ctx = contextvars.copy_context()
|
|
174
|
+
return await asyncio.to_thread(lambda: ctx.run(fn, *args, **kwargs))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _worker_of(obj: Any) -> Worker | None:
|
|
178
|
+
"""The worker an object owns, if it has been given one."""
|
|
179
|
+
return getattr(obj, "worker", None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def session(obj: Any, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
183
|
+
"""Run ``fn`` for ``obj`` on the thread that owns its page, and wait.
|
|
184
|
+
|
|
185
|
+
Thread affinity is not about the event loop: Playwright's page has to be
|
|
186
|
+
clicked by the thread that made it, whoever is asking. So this goes through the
|
|
187
|
+
worker whenever there is one, loop or no loop.
|
|
188
|
+
"""
|
|
189
|
+
worker = _worker_of(obj)
|
|
190
|
+
if worker is None:
|
|
191
|
+
return fn(*args, **kwargs)
|
|
192
|
+
return worker.submit(fn, *args, **kwargs).result()
|
truedemo/_version.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The single source of truth for the truedemo version.
|
|
2
|
+
|
|
3
|
+
Every consumer — the package metadata (``pyproject.toml`` reads this attribute
|
|
4
|
+
dynamically), ``truedemo.__version__``, the CLI ``--version``, the provenance and
|
|
5
|
+
job manifests — imports the same string from here. Bump it in exactly one place
|
|
6
|
+
or the distribution and the reported version drift apart.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "2.0.0"
|