protoreel 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.
- protoreel/__init__.py +4 -0
- protoreel/__main__.py +5 -0
- protoreel/audio.py +29 -0
- protoreel/browser.py +37 -0
- protoreel/cli.py +176 -0
- protoreel/encoder.py +127 -0
- protoreel/mcp_server.py +264 -0
- protoreel/page_scripts/__init__.py +47 -0
- protoreel/page_scripts/background.js +32 -0
- protoreel/page_scripts/clock.js +40 -0
- protoreel/page_scripts/cursor.js +14 -0
- protoreel/renderer.py +270 -0
- protoreel/settings.py +89 -0
- protoreel/studio/__init__.py +1 -0
- protoreel/studio/server.py +277 -0
- protoreel/studio/static/index.html +591 -0
- protoreel/takes.py +112 -0
- protoreel/timeline.py +239 -0
- protoreel/workspace.py +158 -0
- protoreel-0.1.0.dist-info/METADATA +192 -0
- protoreel-0.1.0.dist-info/RECORD +24 -0
- protoreel-0.1.0.dist-info/WHEEL +4 -0
- protoreel-0.1.0.dist-info/entry_points.txt +2 -0
- protoreel-0.1.0.dist-info/licenses/LICENSE +21 -0
protoreel/__init__.py
ADDED
protoreel/__main__.py
ADDED
protoreel/audio.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Prototype sound.
|
|
2
|
+
|
|
3
|
+
Browsers will not let headless Chromium record its own audio output, so prototypes
|
|
4
|
+
that make sound can opt in by exposing one function:
|
|
5
|
+
|
|
6
|
+
window.__recorderAudio(startMs, durationMs) -> base64 WAV (or a Promise of one)
|
|
7
|
+
|
|
8
|
+
It should render the sounds that played between `startMs` and `startMs + durationMs`
|
|
9
|
+
on the page's clock (for example by replaying a log of sound events into an
|
|
10
|
+
OfflineAudioContext). Protoreel calls it once after the last frame and lays the WAV
|
|
11
|
+
under the video. Pages without the function simply produce a silent video.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def page_soundtrack(page, start_ms: float, duration_ms: float) -> tuple[bytes | None, str]:
|
|
20
|
+
"""Ask the page for its soundtrack. Returns (wav bytes or None, status message)."""
|
|
21
|
+
try:
|
|
22
|
+
if not await page.evaluate("typeof window.__recorderAudio === 'function'"):
|
|
23
|
+
return None, "none"
|
|
24
|
+
encoded = await page.evaluate("([s, d]) => window.__recorderAudio(s, d)", [start_ms, duration_ms])
|
|
25
|
+
if not encoded:
|
|
26
|
+
return None, "empty"
|
|
27
|
+
return base64.b64decode(encoded), "added"
|
|
28
|
+
except Exception as error:
|
|
29
|
+
return None, f"failed: {error}"
|
protoreel/browser.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Headless Chromium via Playwright, installing it on first use if needed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
INSTALL_HINT = "Run `protoreel setup` (or `python -m playwright install chromium`) and try again."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def install_chromium(quiet: bool = False) -> None:
|
|
13
|
+
"""Download Playwright's Chromium (about 150 MB, once per machine)."""
|
|
14
|
+
subprocess.run(
|
|
15
|
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
16
|
+
check=True,
|
|
17
|
+
stdout=subprocess.DEVNULL if quiet else None,
|
|
18
|
+
stderr=subprocess.STDOUT if quiet else None,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def launch(playwright):
|
|
23
|
+
"""Launch Chromium. If the browser has not been downloaded yet, download it once and retry.
|
|
24
|
+
|
|
25
|
+
Set PROTOREEL_CHROMIUM to use a Chromium/Chrome binary you already have.
|
|
26
|
+
"""
|
|
27
|
+
executable = os.environ.get("PROTOREEL_CHROMIUM") or None
|
|
28
|
+
try:
|
|
29
|
+
return await playwright.chromium.launch(executable_path=executable)
|
|
30
|
+
except Exception as error:
|
|
31
|
+
if executable or "Executable doesn't exist" not in str(error):
|
|
32
|
+
raise
|
|
33
|
+
try:
|
|
34
|
+
install_chromium(quiet=True)
|
|
35
|
+
except Exception as error:
|
|
36
|
+
raise RuntimeError(f"Chromium is not installed and could not be downloaded. {INSTALL_HINT}") from error
|
|
37
|
+
return await playwright.chromium.launch()
|
protoreel/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Command line: `protoreel <command>`.
|
|
2
|
+
|
|
3
|
+
protoreel studio open the Studio (capture takes by hand, render them)
|
|
4
|
+
protoreel record PAGE SCRIPT film a scripted interaction
|
|
5
|
+
protoreel render-take TAKE film a take captured in the Studio
|
|
6
|
+
protoreel mcp run the MCP server (your AI client starts this for you)
|
|
7
|
+
protoreel setup download Chromium and print MCP client config
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .browser import install_chromium
|
|
21
|
+
from .encoder import find_ffmpeg
|
|
22
|
+
from .renderer import RenderOptions, output_path, render
|
|
23
|
+
from .settings import DEFAULT_FORMAT, DEFAULT_FPS, DEFAULT_RESOLUTION, DEFAULT_ZOOM, FORMATS, RESOLUTIONS
|
|
24
|
+
from .takes import Take
|
|
25
|
+
from .workspace import Workspace
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_folder(parser: argparse.ArgumentParser) -> None:
|
|
29
|
+
parser.add_argument("--folder", help="workspace folder (default: $PROTOREEL_HOME or ~/Protoreel)")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_video_options(parser: argparse.ArgumentParser, format_default: str | None = DEFAULT_FORMAT) -> None:
|
|
33
|
+
parser.add_argument("--format", choices=list(FORMATS), default=format_default, help="video shape")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--zoom",
|
|
36
|
+
type=float,
|
|
37
|
+
default=None if format_default is None else DEFAULT_ZOOM,
|
|
38
|
+
help="0.6–3.6; higher makes the prototype bigger in frame",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--res", choices=list(RESOLUTIONS), default=DEFAULT_RESOLUTION, help="export resolution")
|
|
41
|
+
parser.add_argument("--fps", type=int, choices=(30, 60), default=DEFAULT_FPS)
|
|
42
|
+
parser.add_argument("--no-cursor", action="store_true", help="hide the on-screen pointer")
|
|
43
|
+
parser.add_argument("--no-sound", action="store_true", help="skip the prototype's own sound")
|
|
44
|
+
parser.add_argument("--bg", help="background colour behind the page, e.g. '#ffffff'")
|
|
45
|
+
parser.add_argument("--caption", help="small caption near the bottom of the frame")
|
|
46
|
+
parser.add_argument("--out", help="output file name (saved in renders/)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _options(args, fmt: str, zoom: float) -> RenderOptions:
|
|
50
|
+
return RenderOptions(
|
|
51
|
+
format=fmt,
|
|
52
|
+
zoom=zoom,
|
|
53
|
+
resolution=args.res,
|
|
54
|
+
fps=args.fps,
|
|
55
|
+
cursor=not args.no_cursor,
|
|
56
|
+
sound=not args.no_sound,
|
|
57
|
+
background=args.bg,
|
|
58
|
+
caption=args.caption,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _progress(done: int, total: int | None) -> None:
|
|
63
|
+
if done % 10 == 0 or done == total:
|
|
64
|
+
text = f" frame {done}" + (f" of {total}" if total else "")
|
|
65
|
+
print(f"\r{text}", end="", file=sys.stderr, flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------- commands
|
|
69
|
+
def cmd_studio(args) -> None:
|
|
70
|
+
from .studio.server import Studio
|
|
71
|
+
|
|
72
|
+
studio = Studio(Workspace.open(args.folder), args.port)
|
|
73
|
+
try:
|
|
74
|
+
studio.start()
|
|
75
|
+
except OSError:
|
|
76
|
+
print(f"The Studio is already running at {studio.url}")
|
|
77
|
+
if not args.no_browser:
|
|
78
|
+
studio.open_in_browser()
|
|
79
|
+
return
|
|
80
|
+
print(f"Protoreel Studio is running at {studio.url} (workspace: {studio.workspace.folder})")
|
|
81
|
+
print("Press Ctrl+C or Quit in the app to stop.")
|
|
82
|
+
if not args.no_browser:
|
|
83
|
+
studio.open_in_browser()
|
|
84
|
+
studio.serve_forever()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_record(args) -> None:
|
|
88
|
+
workspace = Workspace.open(args.folder)
|
|
89
|
+
page = args.page if args.page.startswith(("http://", "https://")) else workspace.find_prototype(args.page)
|
|
90
|
+
script = json.loads(Path(args.script).read_text(encoding="utf-8"))
|
|
91
|
+
options = _options(args, args.format, args.zoom)
|
|
92
|
+
out = output_path(workspace.renders, str(page), options, args.out)
|
|
93
|
+
result = asyncio.run(render(page, script, options, out, _progress))
|
|
94
|
+
print(f"\nSaved {result.summary()}\n{result.path}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_render_take(args) -> None:
|
|
98
|
+
workspace = Workspace.open(args.folder)
|
|
99
|
+
take = Take.load(workspace.find_take(args.take))
|
|
100
|
+
options = _options(args, args.format or take.format, args.zoom or take.zoom)
|
|
101
|
+
page = workspace.find_prototype(take.page)
|
|
102
|
+
out = output_path(workspace.renders, page.name, options, args.out)
|
|
103
|
+
result = asyncio.run(render(page, take, options, out, _progress))
|
|
104
|
+
print(f"\nSaved {result.summary()}\n{result.path}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_mcp(args) -> None:
|
|
108
|
+
from .mcp_server import run
|
|
109
|
+
|
|
110
|
+
run(args.folder, args.studio_port)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_setup(args) -> None:
|
|
114
|
+
print(f"Protoreel {__version__}")
|
|
115
|
+
print(f"ffmpeg: {find_ffmpeg()}")
|
|
116
|
+
print("Chromium: downloading if needed…")
|
|
117
|
+
install_chromium()
|
|
118
|
+
folder = str(Workspace.open(args.folder).folder)
|
|
119
|
+
print(f"Workspace: {folder}\n")
|
|
120
|
+
print(client_config(folder))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def client_config(folder: str) -> str:
|
|
124
|
+
"""Copy-paste snippets for the common MCP clients."""
|
|
125
|
+
uvx = shutil.which("uvx") or "uvx"
|
|
126
|
+
server = {"command": uvx, "args": ["protoreel", "mcp", "--folder", folder]}
|
|
127
|
+
desktop = json.dumps({"mcpServers": {"protoreel": server}}, indent=2)
|
|
128
|
+
return (
|
|
129
|
+
"Claude Desktop / Cowork — add to claude_desktop_config.json:\n" + desktop + "\n\n"
|
|
130
|
+
f'Claude Code:\n claude mcp add protoreel -- uvx protoreel mcp --folder "{folder}"\n\n'
|
|
131
|
+
'Cursor (.cursor/mcp.json) / VS Code (.vscode/mcp.json, key "servers"):\n'
|
|
132
|
+
+ json.dumps({"mcpServers": {"protoreel": server}}, indent=2)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
137
|
+
parser = argparse.ArgumentParser(prog="protoreel", description="Turn HTML prototypes into social videos.")
|
|
138
|
+
parser.add_argument("--version", action="version", version=f"protoreel {__version__}")
|
|
139
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
140
|
+
|
|
141
|
+
studio = commands.add_parser("studio", help="open the Studio web app")
|
|
142
|
+
_add_folder(studio)
|
|
143
|
+
studio.add_argument("--port", type=int, default=8765)
|
|
144
|
+
studio.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
|
|
145
|
+
studio.set_defaults(run=cmd_studio)
|
|
146
|
+
|
|
147
|
+
record = commands.add_parser("record", help="film a scripted interaction")
|
|
148
|
+
record.add_argument("page", help="prototype file or http(s) URL")
|
|
149
|
+
record.add_argument("script", help="JSON script (see examples/)")
|
|
150
|
+
_add_folder(record)
|
|
151
|
+
_add_video_options(record)
|
|
152
|
+
record.set_defaults(run=cmd_record)
|
|
153
|
+
|
|
154
|
+
take = commands.add_parser("render-take", help="film a take captured in the Studio")
|
|
155
|
+
take.add_argument("take", help="take file name in takes/ or a path")
|
|
156
|
+
_add_folder(take)
|
|
157
|
+
_add_video_options(take, format_default=None)
|
|
158
|
+
take.set_defaults(run=cmd_render_take)
|
|
159
|
+
|
|
160
|
+
mcp = commands.add_parser("mcp", help="run the MCP server over stdio")
|
|
161
|
+
_add_folder(mcp)
|
|
162
|
+
mcp.add_argument("--studio-port", type=int, default=8765)
|
|
163
|
+
mcp.set_defaults(run=cmd_mcp)
|
|
164
|
+
|
|
165
|
+
setup = commands.add_parser("setup", help="install Chromium and print client config")
|
|
166
|
+
_add_folder(setup)
|
|
167
|
+
setup.set_defaults(run=cmd_setup)
|
|
168
|
+
return parser
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> None:
|
|
172
|
+
args = build_parser().parse_args(argv)
|
|
173
|
+
try:
|
|
174
|
+
args.run(args)
|
|
175
|
+
except (FileNotFoundError, ValueError) as error:
|
|
176
|
+
sys.exit(f"protoreel: {error}")
|
protoreel/encoder.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Video encoding with ffmpeg.
|
|
2
|
+
|
|
3
|
+
Frames arrive as PNG bytes and are piped straight into ffmpeg, which writes an H.264
|
|
4
|
+
MP4 that plays everywhere (yuv420p, +faststart). If ffmpeg is not installed, the copy
|
|
5
|
+
bundled with the `imageio-ffmpeg` package is used, so no separate install is needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .settings import VIDEO_CRF
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_ffmpeg() -> str:
|
|
19
|
+
"""Path to an ffmpeg binary: the system one if present, else the bundled one."""
|
|
20
|
+
for candidate in (shutil.which("ffmpeg"), "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"):
|
|
21
|
+
if candidate and Path(candidate).exists():
|
|
22
|
+
return candidate
|
|
23
|
+
try:
|
|
24
|
+
import imageio_ffmpeg
|
|
25
|
+
|
|
26
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
27
|
+
except Exception as error: # pragma: no cover - only when both are missing
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
"ffmpeg was not found. Install it (macOS: brew install ffmpeg) "
|
|
30
|
+
"or reinstall Protoreel so its bundled copy is available."
|
|
31
|
+
) from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VideoEncoder:
|
|
35
|
+
"""Context manager: `with VideoEncoder(path, fps, w, h) as enc: enc.add_frame(png)`."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, out: Path, fps: int, width: int, height: int, crf: int = VIDEO_CRF):
|
|
38
|
+
self.out = out
|
|
39
|
+
self._cmd = [
|
|
40
|
+
find_ffmpeg(),
|
|
41
|
+
"-v",
|
|
42
|
+
"error",
|
|
43
|
+
"-y",
|
|
44
|
+
"-f",
|
|
45
|
+
"image2pipe",
|
|
46
|
+
"-framerate",
|
|
47
|
+
str(fps),
|
|
48
|
+
"-c:v",
|
|
49
|
+
"png",
|
|
50
|
+
"-i",
|
|
51
|
+
"-",
|
|
52
|
+
"-c:v",
|
|
53
|
+
"libx264",
|
|
54
|
+
"-pix_fmt",
|
|
55
|
+
"yuv420p",
|
|
56
|
+
"-preset",
|
|
57
|
+
"medium",
|
|
58
|
+
"-crf",
|
|
59
|
+
str(crf),
|
|
60
|
+
"-vf",
|
|
61
|
+
f"scale={width}:{height}:flags=lanczos",
|
|
62
|
+
"-movflags",
|
|
63
|
+
"+faststart",
|
|
64
|
+
str(out),
|
|
65
|
+
]
|
|
66
|
+
self._process: subprocess.Popen | None = None
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> VideoEncoder:
|
|
69
|
+
self.out.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._process = subprocess.Popen(self._cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def add_frame(self, png: bytes) -> None:
|
|
74
|
+
assert self._process and self._process.stdin
|
|
75
|
+
self._process.stdin.write(png)
|
|
76
|
+
|
|
77
|
+
def __exit__(self, exc_type, *rest) -> None:
|
|
78
|
+
assert self._process and self._process.stdin
|
|
79
|
+
self._process.stdin.close()
|
|
80
|
+
stderr = self._process.stderr.read().decode(errors="replace") if self._process.stderr else ""
|
|
81
|
+
code = self._process.wait()
|
|
82
|
+
if code != 0 and exc_type is None:
|
|
83
|
+
raise RuntimeError(f"ffmpeg could not encode the video: {stderr.strip()[-400:]}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_soundtrack(video: Path, wav: bytes) -> bool:
|
|
87
|
+
"""Lay a WAV soundtrack under a finished video (AAC, trimmed to the video's length)."""
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
sound = Path(tmp) / "sound.wav"
|
|
90
|
+
sound.write_bytes(wav)
|
|
91
|
+
muxed = Path(tmp) / f"muxed{video.suffix}"
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
[
|
|
94
|
+
find_ffmpeg(),
|
|
95
|
+
"-v",
|
|
96
|
+
"error",
|
|
97
|
+
"-y",
|
|
98
|
+
"-i",
|
|
99
|
+
str(video),
|
|
100
|
+
"-i",
|
|
101
|
+
str(sound),
|
|
102
|
+
"-map",
|
|
103
|
+
"0:v:0",
|
|
104
|
+
"-map",
|
|
105
|
+
"1:a:0",
|
|
106
|
+
"-c:v",
|
|
107
|
+
"copy",
|
|
108
|
+
"-c:a",
|
|
109
|
+
"aac",
|
|
110
|
+
"-b:a",
|
|
111
|
+
"192k",
|
|
112
|
+
"-shortest",
|
|
113
|
+
"-movflags",
|
|
114
|
+
"+faststart",
|
|
115
|
+
str(muxed),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
if result.returncode != 0:
|
|
119
|
+
return False
|
|
120
|
+
shutil.move(str(muxed), str(video))
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def has_audio(video: Path) -> bool:
|
|
125
|
+
"""Cheap check used by the renders list: MP4s with sound contain a 'soun' handler box."""
|
|
126
|
+
with open(video, "rb") as handle:
|
|
127
|
+
return b"soun" in handle.read(262144)
|
protoreel/mcp_server.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Protoreel as an MCP server, so AI clients (Claude, Cursor, VS Code…) can film prototypes.
|
|
2
|
+
|
|
3
|
+
Start it with `protoreel mcp --folder ~/Prototypes`. Your MCP client launches this for you
|
|
4
|
+
once it is configured (see README.md); you never need to open a terminal.
|
|
5
|
+
|
|
6
|
+
Tools
|
|
7
|
+
-----
|
|
8
|
+
list_workspace what can be recorded, plus recent takes and videos
|
|
9
|
+
record_video film a prototype by following a script of clicks, drags and typing
|
|
10
|
+
render_take film a take someone captured by hand in the Studio, in any format
|
|
11
|
+
preview_frame a single PNG frame, to check framing and zoom before a full render
|
|
12
|
+
open_studio open the Studio in the browser to capture a take by hand
|
|
13
|
+
check_setup confirm Chromium and ffmpeg are ready
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
import inspect
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from mcp.server.mcpserver import Context, Image, MCPServer
|
|
25
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
26
|
+
from mcp.types import ToolAnnotations
|
|
27
|
+
|
|
28
|
+
from . import HOMEPAGE, __version__
|
|
29
|
+
from .browser import install_chromium
|
|
30
|
+
from .encoder import find_ffmpeg, has_audio
|
|
31
|
+
from .renderer import RenderOptions, output_path, render, still
|
|
32
|
+
from .settings import FORMATS, RESOLUTIONS
|
|
33
|
+
from .studio.server import Studio
|
|
34
|
+
from .takes import Take
|
|
35
|
+
from .timeline import __doc__ as SCRIPT_GUIDE
|
|
36
|
+
from .workspace import Workspace
|
|
37
|
+
|
|
38
|
+
INSTRUCTIONS = f"""Protoreel films HTML prototypes into social-media videos (MP4) with a smooth on-screen cursor.
|
|
39
|
+
|
|
40
|
+
Typical flow:
|
|
41
|
+
1. list_workspace to see the prototypes (.html files) in the workspace folder.
|
|
42
|
+
2. preview_frame to check framing; raise `zoom` to make the prototype larger in frame.
|
|
43
|
+
3. record_video with a script that performs the interaction. Use CSS selectors from the
|
|
44
|
+
prototype's HTML so clicks land on the right elements.
|
|
45
|
+
4. Tell the user where the video was saved. To re-cut a hand-made take, use render_take.
|
|
46
|
+
|
|
47
|
+
Formats: {", ".join(f"{f.key} ({f.use})" for f in FORMATS.values())}.
|
|
48
|
+
Resolutions: {", ".join(RESOLUTIONS)} (same framing, more pixels).
|
|
49
|
+
|
|
50
|
+
Script format
|
|
51
|
+
{SCRIPT_GUIDE}"""
|
|
52
|
+
|
|
53
|
+
# Problems the user can fix (a wrong file name, a bad option, a missing selector) are sent
|
|
54
|
+
# back to the AI client as readable messages instead of a generic "tool failed".
|
|
55
|
+
EXPECTED_ERRORS = (FileNotFoundError, ValueError, KeyError, RuntimeError)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def user_errors(tool):
|
|
59
|
+
"""Decorator: turn expected errors into ToolErrors with the original message."""
|
|
60
|
+
if inspect.iscoroutinefunction(tool):
|
|
61
|
+
|
|
62
|
+
@functools.wraps(tool)
|
|
63
|
+
async def run_async(*args, **kwargs):
|
|
64
|
+
try:
|
|
65
|
+
return await tool(*args, **kwargs)
|
|
66
|
+
except EXPECTED_ERRORS as error:
|
|
67
|
+
raise ToolError(str(error)) from error
|
|
68
|
+
|
|
69
|
+
return run_async
|
|
70
|
+
|
|
71
|
+
@functools.wraps(tool)
|
|
72
|
+
def run(*args, **kwargs):
|
|
73
|
+
try:
|
|
74
|
+
return tool(*args, **kwargs)
|
|
75
|
+
except EXPECTED_ERRORS as error:
|
|
76
|
+
raise ToolError(str(error)) from error
|
|
77
|
+
|
|
78
|
+
return run
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_server(workspace: Workspace, studio_port: int = 8765) -> MCPServer:
|
|
82
|
+
"""Create the MCP server for one workspace folder."""
|
|
83
|
+
server = MCPServer(name="protoreel", version=__version__, instructions=INSTRUCTIONS, website_url=HOMEPAGE)
|
|
84
|
+
studio = Studio(workspace, studio_port)
|
|
85
|
+
|
|
86
|
+
def options_from(
|
|
87
|
+
fmt: str,
|
|
88
|
+
zoom: float,
|
|
89
|
+
resolution: str,
|
|
90
|
+
fps: int,
|
|
91
|
+
cursor: bool,
|
|
92
|
+
sound: bool,
|
|
93
|
+
background: str | None,
|
|
94
|
+
caption: str | None,
|
|
95
|
+
) -> RenderOptions:
|
|
96
|
+
options = RenderOptions(
|
|
97
|
+
format=fmt,
|
|
98
|
+
zoom=zoom,
|
|
99
|
+
resolution=resolution,
|
|
100
|
+
fps=fps,
|
|
101
|
+
cursor=cursor,
|
|
102
|
+
sound=sound,
|
|
103
|
+
background=background or None,
|
|
104
|
+
caption=caption or None,
|
|
105
|
+
)
|
|
106
|
+
options.geometry() # validates the values early, with a readable message
|
|
107
|
+
return options
|
|
108
|
+
|
|
109
|
+
def progress_reporter(ctx: Context):
|
|
110
|
+
async def report(done: int, total: int | None):
|
|
111
|
+
if done % 15 == 0 or done == total:
|
|
112
|
+
await ctx.report_progress(done, total, f"Filming frame {done}" + (f" of {total}" if total else ""))
|
|
113
|
+
|
|
114
|
+
return report
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------ tools
|
|
117
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True))
|
|
118
|
+
@user_errors
|
|
119
|
+
def list_workspace() -> dict[str, Any]:
|
|
120
|
+
"""List the prototypes that can be recorded, plus recent takes and rendered videos."""
|
|
121
|
+
renders = workspace.recent(workspace.renders, "*.mp4", 15)
|
|
122
|
+
return {
|
|
123
|
+
"folder": str(workspace.folder),
|
|
124
|
+
"prototypes": [p.name for p in workspace.prototypes()],
|
|
125
|
+
"takes": [p.name for p in workspace.recent(workspace.takes, "*.json", 15)],
|
|
126
|
+
"renders": [
|
|
127
|
+
{"file": str(p), "size_mb": round(p.stat().st_size / 1048576, 1), "sound": has_audio(p)}
|
|
128
|
+
for p in renders
|
|
129
|
+
],
|
|
130
|
+
"formats": {f.key: f"{f.width}x{f.height} · {f.use}" for f in FORMATS.values()},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@server.tool()
|
|
134
|
+
@user_errors
|
|
135
|
+
async def record_video(
|
|
136
|
+
prototype: str,
|
|
137
|
+
script: dict[str, Any] | str,
|
|
138
|
+
format: str = "4x5",
|
|
139
|
+
zoom: float = 2.0,
|
|
140
|
+
resolution: str = "1080",
|
|
141
|
+
fps: int = 60,
|
|
142
|
+
cursor: bool = True,
|
|
143
|
+
sound: bool = True,
|
|
144
|
+
background: str | None = None,
|
|
145
|
+
caption: str | None = None,
|
|
146
|
+
output_name: str | None = None,
|
|
147
|
+
ctx: Context | None = None,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Film a prototype following a script, and save an MP4 in the workspace's renders/ folder.
|
|
150
|
+
|
|
151
|
+
prototype: file name in the workspace (e.g. "checkout.html"), an absolute path, or an http(s) URL.
|
|
152
|
+
script: {"start": POINT, "actions": [...]} - see the server instructions for every action.
|
|
153
|
+
Example: {"actions": [{"wait": 400}, {"click": "#add", "dur": 700}, {"wait": 1200}]}
|
|
154
|
+
format: 4x5 (feed), 9x16 (reels), 1x1, 16x9, 4x3 or 3x2 (Dribbble).
|
|
155
|
+
zoom: 0.6-3.6. Higher makes the prototype bigger in frame (default 2).
|
|
156
|
+
resolution: 1080, 2k, 3k or 4k. background: optional colour behind the page, e.g. "#ffffff".
|
|
157
|
+
"""
|
|
158
|
+
if isinstance(script, str):
|
|
159
|
+
script = json.loads(script)
|
|
160
|
+
options = options_from(format, zoom, resolution, fps, cursor, sound, background, caption)
|
|
161
|
+
source = prototype if prototype.startswith(("http://", "https://")) else workspace.find_prototype(prototype)
|
|
162
|
+
out = output_path(workspace.renders, str(source), options, output_name)
|
|
163
|
+
result = await render(source, script, options, out, progress_reporter(ctx) if ctx else None)
|
|
164
|
+
return f"Saved {result.path}\n{result.summary()}"
|
|
165
|
+
|
|
166
|
+
@server.tool()
|
|
167
|
+
@user_errors
|
|
168
|
+
async def render_take(
|
|
169
|
+
take: str,
|
|
170
|
+
format: str | None = None,
|
|
171
|
+
zoom: float | None = None,
|
|
172
|
+
resolution: str = "1080",
|
|
173
|
+
fps: int = 60,
|
|
174
|
+
cursor: bool = True,
|
|
175
|
+
smooth: bool = True,
|
|
176
|
+
sound: bool = True,
|
|
177
|
+
background: str | None = None,
|
|
178
|
+
caption: str | None = None,
|
|
179
|
+
ctx: Context | None = None,
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Render a take that was captured by hand in the Studio (a .json file in takes/).
|
|
182
|
+
|
|
183
|
+
Leave format and zoom empty to match how the take was captured, or pick another
|
|
184
|
+
format to re-cut the same performance (the motion is re-centred in the new frame).
|
|
185
|
+
"""
|
|
186
|
+
saved = Take.load(workspace.find_take(take))
|
|
187
|
+
options = options_from(
|
|
188
|
+
format or saved.format, zoom or saved.zoom, resolution, fps, cursor, sound, background, caption
|
|
189
|
+
)
|
|
190
|
+
options.smooth = smooth
|
|
191
|
+
prototype = workspace.find_prototype(saved.page)
|
|
192
|
+
out = output_path(workspace.renders, prototype.name, options)
|
|
193
|
+
result = await render(prototype, saved, options, out, progress_reporter(ctx) if ctx else None)
|
|
194
|
+
return f"Saved {result.path}\n{result.summary()}"
|
|
195
|
+
|
|
196
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True))
|
|
197
|
+
@user_errors
|
|
198
|
+
async def preview_frame(
|
|
199
|
+
prototype: str,
|
|
200
|
+
format: str = "4x5",
|
|
201
|
+
zoom: float = 2.0,
|
|
202
|
+
script: dict[str, Any] | str | None = None,
|
|
203
|
+
at_ms: float | None = None,
|
|
204
|
+
background: str | None = None,
|
|
205
|
+
) -> Image:
|
|
206
|
+
"""Return one frame as an image, exactly as it would appear in the video.
|
|
207
|
+
|
|
208
|
+
Without a script you see the first frame. With a script you see the page after it
|
|
209
|
+
has played (or after `at_ms` milliseconds of it). Use this to tune zoom and format.
|
|
210
|
+
"""
|
|
211
|
+
if isinstance(script, str):
|
|
212
|
+
script = json.loads(script)
|
|
213
|
+
options = options_from(format, zoom, "1080", 60, True, False, background, None)
|
|
214
|
+
source = prototype if prototype.startswith(("http://", "https://")) else workspace.find_prototype(prototype)
|
|
215
|
+
png = await still(source, options, script, at_ms)
|
|
216
|
+
return Image(data=png, format="png")
|
|
217
|
+
|
|
218
|
+
@server.tool()
|
|
219
|
+
@user_errors
|
|
220
|
+
def open_studio(prototype: str | None = None) -> str:
|
|
221
|
+
"""Open the Protoreel Studio in the user's browser to capture a take by hand.
|
|
222
|
+
|
|
223
|
+
Takes captured there are saved to takes/, so you can render them with render_take.
|
|
224
|
+
"""
|
|
225
|
+
if prototype:
|
|
226
|
+
prototype = workspace.find_prototype(prototype).name
|
|
227
|
+
try:
|
|
228
|
+
studio.serve_in_background()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass # the port is busy: almost always a Studio that is already running, so just open it
|
|
231
|
+
return f"The Studio is open at {studio.open_in_browser(prototype)}"
|
|
232
|
+
|
|
233
|
+
@server.tool()
|
|
234
|
+
@user_errors
|
|
235
|
+
async def check_setup(install_missing: bool = False) -> dict[str, str]:
|
|
236
|
+
"""Check that ffmpeg and Chromium are available. Set install_missing to download Chromium."""
|
|
237
|
+
report = {"version": __version__, "workspace": str(workspace.folder)}
|
|
238
|
+
try:
|
|
239
|
+
report["ffmpeg"] = find_ffmpeg()
|
|
240
|
+
except RuntimeError as error:
|
|
241
|
+
report["ffmpeg"] = f"missing: {error}"
|
|
242
|
+
report["chromium"] = await _chromium_status()
|
|
243
|
+
if install_missing and report["chromium"] != "ready":
|
|
244
|
+
install_chromium(quiet=True)
|
|
245
|
+
report["chromium"] = await _chromium_status()
|
|
246
|
+
return report
|
|
247
|
+
|
|
248
|
+
return server
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def _chromium_status() -> str:
|
|
252
|
+
try:
|
|
253
|
+
from playwright.async_api import async_playwright
|
|
254
|
+
|
|
255
|
+
async with async_playwright() as playwright:
|
|
256
|
+
path = Path(playwright.chromium.executable_path)
|
|
257
|
+
return "ready" if path.exists() else "not installed (call check_setup with install_missing=true)"
|
|
258
|
+
except Exception as error:
|
|
259
|
+
return f"unavailable: {error}"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run(folder: str | None = None, studio_port: int = 8765) -> None:
|
|
263
|
+
"""Serve over stdio, the transport desktop MCP clients use."""
|
|
264
|
+
build_server(Workspace.open(folder), studio_port).run("stdio")
|