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 ADDED
@@ -0,0 +1,4 @@
1
+ """Protoreel: turn HTML prototypes into frame-perfect social videos."""
2
+
3
+ __version__ = "0.1.0"
4
+ HOMEPAGE = "https://github.com/iamkingsleey/protoreel"
protoreel/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allows `python -m protoreel ...`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
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)
@@ -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")