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 +64 -0
- vsremote/__main__.py +4 -0
- vsremote/_strides.py +44 -0
- vsremote/api/__init__.py +4 -0
- vsremote/api/info.py +6 -0
- vsremote/api/output.py +97 -0
- vsremote/cli.py +366 -0
- vsremote/client/__init__.py +6 -0
- vsremote/client/client.py +439 -0
- vsremote/client/transport.py +444 -0
- vsremote/exceptions.py +103 -0
- vsremote/protocol/__init__.py +63 -0
- vsremote/protocol/codec.py +114 -0
- vsremote/protocol/constants.py +73 -0
- vsremote/protocol/messages.py +279 -0
- vsremote/py.typed +0 -0
- vsremote/server/__init__.py +8 -0
- vsremote/server/daemon.py +543 -0
- vsremote/server/policy.py +45 -0
- vsremote/server/redirect.py +171 -0
- vsremote/server/runner.py +301 -0
- vsremote/utils.py +45 -0
- vsremote-0.1.0.dist-info/METADATA +262 -0
- vsremote-0.1.0.dist-info/RECORD +27 -0
- vsremote-0.1.0.dist-info/WHEEL +4 -0
- vsremote-0.1.0.dist-info/entry_points.txt +2 -0
- vsremote-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
vsremote/api/__init__.py
ADDED
vsremote/api/info.py
ADDED
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()
|