vsremote 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.
vsremote/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from . import client, exceptions, protocol, server
4
+ from .api import is_preview, set_output
5
+ from .cli import info, keygen, ping, pipe, serve
6
+ from .client import RemoteClient, source
7
+ from .exceptions import (
8
+ EnvironmentNotSetError,
9
+ MalformedMessageError,
10
+ OutputNotFoundError,
11
+ ProtocolError,
12
+ RemoteAuthenticationError,
13
+ RemoteCommandError,
14
+ RemoteError,
15
+ RemoteExecutionError,
16
+ RemoteNotFoundError,
17
+ RemotePayloadError,
18
+ RemotePermissionError,
19
+ RemoteTimeoutError,
20
+ ScriptNotLoadedError,
21
+ ScriptRunnerError,
22
+ TransportClosedError,
23
+ TransportError,
24
+ TransportNotConnectedError,
25
+ TransportNotStartedError,
26
+ UnknownCommandError,
27
+ UnknownStatusCodeError,
28
+ UnsupportedFormatError,
29
+ VSRemoteError,
30
+ )
31
+
32
+ __all__ = [
33
+ "EnvironmentNotSetError",
34
+ "MalformedMessageError",
35
+ "OutputNotFoundError",
36
+ "ProtocolError",
37
+ "RemoteAuthenticationError",
38
+ "RemoteClient",
39
+ "RemoteCommandError",
40
+ "RemoteError",
41
+ "RemoteExecutionError",
42
+ "RemoteNotFoundError",
43
+ "RemotePayloadError",
44
+ "RemotePermissionError",
45
+ "RemoteTimeoutError",
46
+ "ScriptNotLoadedError",
47
+ "ScriptRunnerError",
48
+ "TransportClosedError",
49
+ "TransportError",
50
+ "TransportNotConnectedError",
51
+ "TransportNotStartedError",
52
+ "UnknownCommandError",
53
+ "UnknownStatusCodeError",
54
+ "UnsupportedFormatError",
55
+ "VSRemoteError",
56
+ "client",
57
+ "exceptions",
58
+ "is_preview",
59
+ "protocol",
60
+ "serve",
61
+ "server",
62
+ "set_output",
63
+ "source",
64
+ ]
vsremote/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
vsremote/_strides.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import sys
5
+
6
+ try:
7
+ import mypy_extensions
8
+
9
+ i32 = mypy_extensions.i32
10
+ i64 = mypy_extensions.i64
11
+ except ImportError:
12
+ if sys.version_info >= (3, 14):
13
+ i32 = i64 = int # type: ignore[assignment]
14
+ else:
15
+ i32 = i64 = int
16
+
17
+ _PyBytes_AsString = ctypes.pythonapi.PyBytes_AsString
18
+ _PyBytes_AsString.argtypes = [ctypes.py_object]
19
+ _PyBytes_AsString.restype = ctypes.c_void_p
20
+
21
+ _memmove = ctypes.memmove
22
+
23
+
24
+ def copy_plane_strided(dst_addr: i64, decompressed: bytes, width: i32, height: i32, bps: i32, stride: i64) -> None:
25
+ """
26
+ Copy uncompressed planar bytes to a strided frame buffer line by line.
27
+
28
+ Args:
29
+ dst_addr: Base memory address of destination frame plane.
30
+ decompressed: Raw planar byte string.
31
+ width: Plane width in pixels.
32
+ height: Plane height in lines.
33
+ bps: Number of bytes per sample (e.g. 1 for 8-bit, 2 for 16-bit, 4 for float).
34
+ stride: Destination plane stride in bytes.
35
+ """
36
+ row_size: i64 = i64(width) * i64(bps)
37
+ src: i64 = i64(_PyBytes_AsString(decompressed))
38
+ dst: i64 = dst_addr
39
+ h: i64 = i64(height)
40
+
41
+ for _ in range(h):
42
+ _memmove(dst, src, row_size)
43
+ dst += stride
44
+ src += row_size
@@ -0,0 +1,4 @@
1
+ from .info import is_preview
2
+ from .output import set_output
3
+
4
+ __all__ = ["is_preview", "set_output"]
vsremote/api/info.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+
4
+ def is_preview() -> bool:
5
+ """Check if the current script is running in a preview environment (VSRemote only)."""
6
+ return bool(sys.modules.get("__vsremote__"))
vsremote/api/output.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ Output registration API for vsremote.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from logging import getLogger
9
+ from typing import Any, overload
10
+
11
+ import vapoursynth as vs
12
+
13
+ _logger = getLogger(__name__)
14
+ _output_metadata = dict[int, str]()
15
+
16
+
17
+ @overload
18
+ def set_output(node: vs.RawNode, index: int = ..., /) -> None: ...
19
+ @overload
20
+ def set_output(node: vs.RawNode, name: str | bool | None = ..., /) -> None: ...
21
+ @overload
22
+ def set_output(node: vs.RawNode, index: int = ..., name: str | bool | None = ..., /) -> None: ...
23
+ def set_output(node: vs.RawNode, index_or_name: int | str | bool | None = None, name: str | bool | None = None) -> None:
24
+ """
25
+ Register one or more VapourSynth nodes as outputs for preview.
26
+
27
+ If no index is provided, outputs are assigned to the next available indices.
28
+
29
+ Args:
30
+ node: A VideoNode, AudioNode, or iterable of nodes to output.
31
+ index_or_name: Either:
32
+
33
+ - An int specifying output index
34
+ - A str to use as the output name
35
+ - True/None to auto-detect the variable name
36
+ - False to disable name detection
37
+
38
+ name: Explicit name override. If provided when index_or_name is an int,
39
+ this sets the display name for the output.
40
+ **kwargs: Additional metadata for custom configuration of this output.
41
+ """
42
+ if isinstance(index_or_name, (str, bool)):
43
+ index = None
44
+ name = index_or_name
45
+ else:
46
+ index = index_or_name
47
+
48
+ outputs = vs.get_outputs()
49
+ index = index if index is not None else max(outputs, default=-1) + 1
50
+
51
+ if index in outputs:
52
+ _logger.warning("Output index %d already in use; overwriting.", index)
53
+
54
+ node.set_output(index)
55
+
56
+ if not sys.modules.get("__vsremote__"):
57
+ return
58
+
59
+ effective_name: str | None
60
+
61
+ match name:
62
+ case True | None:
63
+ effective_name = _resolve_var_name(node, frame_depth=2)
64
+ case False:
65
+ effective_name = None
66
+ case str():
67
+ effective_name = name
68
+
69
+ if not effective_name:
70
+ match node:
71
+ case vs.VideoNode():
72
+ title = "Clip"
73
+ case vs.AudioNode():
74
+ title = "Audio"
75
+ case _:
76
+ raise NotImplementedError
77
+ effective_name = f"{title} {index}"
78
+
79
+ _output_metadata[index] = effective_name
80
+
81
+
82
+ def _resolve_var_name(obj: Any, *, frame_depth: int = 1) -> str | None:
83
+ try:
84
+ frame = sys._getframe(frame_depth)
85
+ except ValueError:
86
+ return None
87
+
88
+ try:
89
+ obj_id = id(obj)
90
+
91
+ for var_name, value in reversed(list(frame.f_locals.items())):
92
+ if id(value) == obj_id:
93
+ return var_name
94
+
95
+ return None
96
+ finally:
97
+ del frame
vsremote/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import signal
7
+ import sys
8
+ import threading
9
+ import time
10
+ from contextlib import nullcontext
11
+ from dataclasses import dataclass
12
+ from typing import Annotated, override
13
+
14
+ import vapoursynth as vs
15
+ import zmq
16
+ from cyclopts import App, Parameter
17
+ from cyclopts.help import DefaultFormatter, HelpPanel
18
+ from rich.console import Console, ConsoleOptions
19
+ from rich.table import Table
20
+ from vsengine import ManagedEnvironment, Policy, UnifiedFuture
21
+
22
+ from .client.transport import ClientTransport
23
+ from .exceptions import UnsupportedFormatError
24
+ from .protocol import DEFAULT_ADDRESS, ClipInfo, Compression, FrameHeader, StatusCode, decompress_plane
25
+ from .server import ScriptRunner, ServerDaemon
26
+ from .utils import console, setup_logging
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class CleanHelpFormatter(DefaultFormatter):
32
+ @override
33
+ def __call__(self, console: Console, options: ConsoleOptions, panel: HelpPanel) -> None:
34
+ panel.entries = [
35
+ entry.copy(positive_names=entry.positive_names[1:]) # type: ignore[no-untyped-call]
36
+ if len(entry.positive_names) > 1 and not entry.positive_names[0].startswith("-")
37
+ else entry
38
+ for entry in panel.entries
39
+ ]
40
+ super().__call__(console, options, panel)
41
+
42
+
43
+ app = App("vsremote", console=console, default_parameter=Parameter(negative=()), help_formatter=CleanHelpFormatter())
44
+
45
+
46
+ @Parameter(name="*")
47
+ @dataclass(frozen=True)
48
+ class ClientConfig:
49
+ """Connection and authentication parameters for remote server operations."""
50
+
51
+ address: str = DEFAULT_ADDRESS
52
+ """Remote server address (e.g. tcp://127.0.0.1:5555 or ipc:///tmp/vsremote.sock)."""
53
+
54
+ auth_token: Annotated[str | None, Parameter(env_var="VSREMOTE_AUTH_TOKEN")] = None
55
+ """Optional shared secret authentication token."""
56
+
57
+ curve_server_key: Annotated[str | None, Parameter(env_var="VSREMOTE_CURVE_SERVER_KEY")] = None
58
+ """Optional CurveZMQ server public key."""
59
+
60
+ curve_public_key: Annotated[str | None, Parameter(env_var="VSREMOTE_CURVE_PUBLIC_KEY")] = None
61
+ """Optional CurveZMQ client public key."""
62
+
63
+ curve_secret_key: Annotated[str | None, Parameter(env_var="VSREMOTE_CURVE_SECRET_KEY")] = None
64
+ """Optional CurveZMQ client secret key."""
65
+
66
+ def create_transport(self, *, subscribe_streams: bool = False) -> ClientTransport:
67
+ return ClientTransport(
68
+ self.address,
69
+ auth_token=self.auth_token,
70
+ curve_server_key=self.curve_server_key,
71
+ curve_public_key=self.curve_public_key,
72
+ curve_secret_key=self.curve_secret_key,
73
+ subscribe_streams=subscribe_streams,
74
+ )
75
+
76
+
77
+ DEFAULT_CLIENT_CONFIG = ClientConfig()
78
+
79
+
80
+ @app.command
81
+ def serve(
82
+ script_path: str | os.PathLike[str] | None = None,
83
+ /,
84
+ *,
85
+ address: str = DEFAULT_ADDRESS,
86
+ compression: Compression = "zstd",
87
+ max_workers: Annotated[int | None, Parameter(env_var="VSREMOTE_MAX_WORKERS")] = None,
88
+ allow_eval: Annotated[bool, Parameter(env_var="VSREMOTE_ALLOW_EVAL")] = False,
89
+ auth_token: Annotated[str | None, Parameter(env_var="VSREMOTE_AUTH_TOKEN")] = None,
90
+ curve: bool = False,
91
+ curve_secret_key: Annotated[str | None, Parameter(env_var="VSREMOTE_CURVE_SECRET_KEY")] = None,
92
+ curve_public_key: Annotated[str | None, Parameter(env_var="VSREMOTE_CURVE_PUBLIC_KEY")] = None,
93
+ # Not exposed to the CLI
94
+ ready_event: Annotated[threading.Event | None, Parameter(show=False)] = None,
95
+ stop_event: Annotated[threading.Event | None, Parameter(show=False)] = None,
96
+ environment: Annotated[Policy | ManagedEnvironment | None, Parameter(show=False)] = None,
97
+ ) -> None:
98
+ """
99
+ Host a VapourSynth script on the network.
100
+
101
+ Args:
102
+ script_path: Path to the .vpy script file.
103
+ address: Network or IPC address to bind (e.g. tcp://127.0.0.1:5555 or ipc:///tmp/vsremote.sock).
104
+ compression: Compression mode for video frames.
105
+ max_workers: Worker thread pool size for compression.
106
+ allow_eval: Allow remote clients to execute dynamic Python code or switch scripts.
107
+ auth_token: Optional shared secret authentication token.
108
+ curve: Automatically generate an ephemeral CurveZMQ keypair for this session.
109
+ curve_secret_key: Optional CurveZMQ server secret key for end-to-end encryption.
110
+ curve_public_key: Optional CurveZMQ server public key.
111
+ """
112
+ if curve and not curve_secret_key:
113
+ pub, sec = zmq.curve_keypair()
114
+ curve_public_key = pub.decode("ascii")
115
+ curve_secret_key = sec.decode("ascii")
116
+ logger.info("CurveZMQ encryption enabled. Client public key: %s", curve_public_key)
117
+
118
+ runner = (
119
+ ScriptRunner.from_script(script_path, environment=environment)
120
+ if script_path
121
+ else ScriptRunner(environment=environment)
122
+ )
123
+
124
+ with runner:
125
+ daemon = ServerDaemon(
126
+ runner,
127
+ address=address,
128
+ compression=compression,
129
+ max_workers=max_workers,
130
+ allow_eval=allow_eval,
131
+ auth_token=auth_token,
132
+ curve_secret_key=curve_secret_key,
133
+ curve_public_key=curve_public_key,
134
+ )
135
+
136
+ async def run() -> None:
137
+ if sys.platform != "win32" and threading.current_thread() is threading.main_thread():
138
+ loop = asyncio.get_running_loop()
139
+ for sig in (signal.SIGINT, signal.SIGTERM):
140
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(daemon.stop()))
141
+
142
+ wakeup_task = None
143
+ else:
144
+ wakeup_task = asyncio.create_task(_wakeup(), name="wakeup")
145
+
146
+ stop_task = asyncio.create_task(_watch_stop(daemon, stop_event), name="watch_stop") if stop_event else None
147
+ try:
148
+ await daemon.start(ready_event=ready_event)
149
+ finally:
150
+ if wakeup_task:
151
+ wakeup_task.cancel()
152
+ if stop_task:
153
+ stop_task.cancel()
154
+
155
+ try:
156
+ asyncio.run(run(), loop_factory=asyncio.SelectorEventLoop)
157
+ except KeyboardInterrupt:
158
+ logger.info("Keyboard interrupt received, shutting down server...")
159
+
160
+
161
+ @app.command
162
+ def ping(config: ClientConfig = DEFAULT_CLIENT_CONFIG) -> None:
163
+ """Check connectivity and liveness to a remote vs-remote server."""
164
+ with config.create_transport(subscribe_streams=False) as transport:
165
+ t0 = time.perf_counter()
166
+ ok = transport.ping().result(timeout=10.0)
167
+ lat = (time.perf_counter() - t0) * 1000.0
168
+
169
+ if ok:
170
+ console.print(
171
+ f"[bold green]OK[/bold green] - Successfully connected to [cyan]{config.address}[/cyan] "
172
+ f"(RTT: [yellow]{lat:.2f}ms[/yellow])"
173
+ )
174
+ else:
175
+ console.print(f"[bold red]FAIL[/bold red] - Ping failed for [cyan]{config.address}[/cyan]")
176
+ raise SystemExit(1)
177
+
178
+
179
+ @app.command
180
+ def info(config: ClientConfig = DEFAULT_CLIENT_CONFIG) -> None:
181
+ """Query and display metadata for all outputs available on the remote server."""
182
+ with config.create_transport(subscribe_streams=False) as transport:
183
+ outputs = transport.list_outputs().result(timeout=10.0)
184
+
185
+ table = Table(title=f"Remote Outputs for {config.address}")
186
+ table.add_column("Index", justify="right", style="cyan", no_wrap=True)
187
+ table.add_column("Name", style="magenta")
188
+ table.add_column("Resolution", justify="center", style="green")
189
+ table.add_column("FPS", justify="center")
190
+ table.add_column("Format", style="yellow")
191
+ table.add_column("Frames", justify="right", style="blue")
192
+
193
+ for item in outputs:
194
+ clip_info = item.info
195
+ fps_str = f"{clip_info.fps_num / clip_info.fps_den:.3f}" if clip_info.fps_den else f"{clip_info.fps_num}"
196
+ table.add_row(
197
+ str(item.index),
198
+ item.name,
199
+ f"{clip_info.width}x{clip_info.height}",
200
+ f"{fps_str} ({clip_info.fps_num}/{clip_info.fps_den})",
201
+ clip_info.format_name,
202
+ str(clip_info.num_frames),
203
+ )
204
+
205
+ console.print(table)
206
+
207
+
208
+ @app.command
209
+ def pipe(
210
+ config: ClientConfig = DEFAULT_CLIENT_CONFIG,
211
+ *,
212
+ output: int = 0,
213
+ y4m: bool = False,
214
+ prefetch: int = 8,
215
+ backlog: int | None = None,
216
+ compression: Compression = "zstd",
217
+ environment: Annotated[Policy | ManagedEnvironment | None, Parameter(show=False)] = None,
218
+ ) -> None:
219
+ """
220
+ Stream video frames directly from the remote server to stdout in Y4M format or raw planes.
221
+
222
+ Args:
223
+ output: Output clip index on the remote server.
224
+ y4m: Output standard Y4M (YUV4MPEG2) header and frame tags.
225
+ prefetch: Number of frames to prefetch ahead concurrently (0 to disable).
226
+ backlog: Maximum number of in-flight and prefetched frame requests buffered
227
+ (defaults to max(prefetch * 3, prefetch)).
228
+ compression: Frame transport compression.
229
+ """
230
+ with config.create_transport(subscribe_streams=False) as transport:
231
+ clip_info = transport.get_clip_info(output).result(timeout=10.0)
232
+
233
+ stdout_buf = sys.stdout.buffer
234
+
235
+ if y4m:
236
+ stdout_buf.write(_get_y4m_header(clip_info, environment))
237
+ stdout_buf.flush()
238
+
239
+ prefetch_count = max(0, prefetch)
240
+ backlog_count = max(prefetch_count, backlog if backlog is not None else prefetch_count * 3)
241
+
242
+ inflight = dict[int, UnifiedFuture[tuple[FrameHeader, list[bytes]]]]()
243
+
244
+ for n in range(clip_info.num_frames):
245
+ if prefetch_count > 0:
246
+ while len(inflight) < backlog_count:
247
+ next_to_request = n + len(inflight)
248
+ if next_to_request >= clip_info.num_frames or next_to_request > n + prefetch_count:
249
+ break
250
+ if next_to_request not in inflight:
251
+ inflight[next_to_request] = transport.request_frame(
252
+ output, next_to_request, compression=compression
253
+ )
254
+ else:
255
+ break
256
+
257
+ if (fut := inflight.pop(n, None)) is None:
258
+ fut = transport.request_frame(output, n, compression=compression)
259
+
260
+ header, plane_parts = fut.result(timeout=30.0)
261
+
262
+ if header.status != StatusCode.OK:
263
+ header.status.raise_for_status(f"Failed to fetch frame {n}: {header.error_message}")
264
+
265
+ if y4m:
266
+ stdout_buf.write(b"FRAME\n")
267
+
268
+ for p, compressed in enumerate(plane_parts):
269
+ decompressed = decompress_plane(compressed, clip_info.planes[p].size_bytes, header.compression)
270
+ stdout_buf.write(decompressed)
271
+
272
+ stdout_buf.flush()
273
+
274
+
275
+ @app.command
276
+ def keygen() -> None:
277
+ """
278
+ Generate a new Curve25519 keypair for CurveZMQ transport encryption.
279
+ """
280
+ pub, sec = zmq.curve_keypair()
281
+ pub_str = pub.decode("ascii")
282
+ sec_str = sec.decode("ascii")
283
+
284
+ console.print("[bold green]Generated CurveZMQ Keypair:[/bold green]\n")
285
+ console.print(f" [bold]Public Key:[/bold] [cyan]{pub_str}[/cyan]")
286
+ console.print(f" [bold]Secret Key:[/bold] [yellow]{sec_str}[/yellow]\n")
287
+ console.print("[bold dim]Usage:[/bold dim]")
288
+ console.print(f' Server: vsremote serve script.vpy --curve-secret-key "{sec_str}"')
289
+ console.print(f' Client: vsremote.source("tcp://...", curve_server_key="{pub_str}")\n')
290
+
291
+
292
+ @app.meta.default
293
+ def main_meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], verbose: bool = False) -> None:
294
+ """
295
+ High-performance remote execution server for VapourSynth
296
+
297
+ Args:
298
+ verbose: Enable debug logging.
299
+ """
300
+ setup_logging(level=logging.DEBUG if verbose else logging.INFO)
301
+
302
+ if tokens:
303
+ app(tokens)
304
+
305
+
306
+ def main() -> None:
307
+ app.meta()
308
+
309
+
310
+ async def _watch_stop(daemon: ServerDaemon, stop_event: threading.Event) -> None:
311
+ await asyncio.get_running_loop().run_in_executor(None, stop_event.wait)
312
+ await daemon.stop()
313
+
314
+
315
+ async def _wakeup() -> None:
316
+ # Heartbeat required on Windows to process SIGINT
317
+ while True: # noqa: ASYNC110
318
+ await asyncio.sleep(0.5)
319
+
320
+
321
+ def _get_y4m_header(info: ClipInfo, environment: Policy | ManagedEnvironment | None) -> bytes:
322
+ if info.num_planes == 1:
323
+ y4mformat = "mono"
324
+ elif info.num_planes == 3:
325
+ match info.subsampling_w, info.subsampling_h:
326
+ case 1, 1:
327
+ y4mformat = "420"
328
+ case 1, 0:
329
+ y4mformat = "422"
330
+ case 0, 0:
331
+ y4mformat = "444"
332
+ case 2, 2:
333
+ y4mformat = "410"
334
+ case 2, 0:
335
+ y4mformat = "411"
336
+ case 0, 1:
337
+ y4mformat = "440"
338
+ case _:
339
+ raise UnsupportedFormatError(
340
+ f"Unsupported subsampling for Y4M: ({info.subsampling_w}, {info.subsampling_h})"
341
+ )
342
+ else:
343
+ raise UnsupportedFormatError(f"Unsupported number of planes for Y4M: {info.num_planes}")
344
+
345
+ if isinstance(policy := environment, Policy):
346
+ ctx = policy.new_environment().use()
347
+ elif isinstance(environment, ManagedEnvironment):
348
+ ctx = environment.use()
349
+ else:
350
+ ctx = nullcontext()
351
+
352
+ with ctx:
353
+ bits = vs.core.get_video_format(info.format_id).bits_per_sample
354
+
355
+ if bits > 8:
356
+ y4mformat += f"p{bits}"
357
+
358
+ header = (
359
+ f"YUV4MPEG2 C{y4mformat} W{info.width} H{info.height} "
360
+ f"F{info.fps_num}:{info.fps_den} Ip A0:0 XLENGTH={info.num_frames}\n"
361
+ )
362
+ return header.encode("ascii")
363
+
364
+
365
+ if __name__ == "__main__":
366
+ main()
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import RemoteClient, source
4
+ from .transport import ClientTransport
5
+
6
+ __all__ = ["ClientTransport", "RemoteClient", "source"]