├── LICENSE ├── README.md └── TCPClip.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2020 Vladimir Kontserenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TCPClip for VapourSynth 2 | Python class for distributed video processing and encoding 3 | 4 | ## Usage 5 | 6 | ### Server side (adjust threads or don't set varibale for auto-detection) 7 | ```python 8 | from TCPClip import Server 9 | 10 | Server('', 14322, get_output(), threads=8, log_level='info', compression_method=None, compression_level=1, compression_threads=1) 11 | ``` 12 | 13 | #### Batches 14 | ```sh 15 | py EP01.py 16 | py EP02.py 17 | ... 18 | py EP12.py 19 | ``` 20 | 21 | ### Client side (plain encoding) 22 | ```python 23 | from TCPClip import Client 24 | Client('', port=14322, log_level='info', shutdown=True).to_stdout() 25 | ``` 26 | 27 | #### Batches (plain encoding) 28 | ```sh 29 | py client.py | x264 ... --demuxer "y4m" --output "EP01.264" - 30 | py client.py | x264 ... --demuxer "y4m" --output "EP02.264" - 31 | ... 32 | py client.py | x264 ... --demuxer "y4m" --output "EP12.264" - 33 | ``` 34 | 35 | ### Client side (VS Source mode) 36 | ```python 37 | from TCPClip import Client 38 | from vapoursynth import core 39 | clip = Client('', port=14322, log_level='info', shutdown=True).as_source() 40 | 41 | clip.set_output() 42 | ``` 43 | 44 | #### Batches (VS Source mode) 45 | ```sh 46 | vspipe -y EP01.vpy - | x264 ... --demuxer "y4m" --output "EP01.264" - 47 | vspipe -y EP02.vpy - | x264 ... --demuxer "y4m" --output "EP02.264" - 48 | ... 49 | vspipe -y EP12.vpy - | x264 ... --demuxer "y4m" --output "EP12.264" - 50 | ``` 51 | -------------------------------------------------------------------------------- /TCPClip.py: -------------------------------------------------------------------------------- 1 | # TCPClip Class by DJATOM 2 | # Version 2.4.0 3 | # License: MIT 4 | # Why? Mainly for processing on server 1 and encoding on server 2, but it's also possible to distribute filtering chain. 5 | # 6 | # Usage: 7 | # Server side: 8 | # from TCPClip import Server 9 | # 10 | # Server('', , get_output(), , , , , ) 11 | # Batches: 12 | # py EP01.py 13 | # py EP02.py 14 | # ... 15 | # py EP12.py 16 | # 17 | # Client side (plain encoding): 18 | # from TCPClip import Client 19 | # client = Client('', , ) 20 | # client.to_stdout() 21 | # Batches: 22 | # py client.py | x264 ... --demuxer "y4m" --output "EP01.264" - 23 | # py client.py | x264 ... --demuxer "y4m" --output "EP02.264" - 24 | # ... 25 | # py client.py | x264 ... --demuxer "y4m" --output "EP12.264" - 26 | # 27 | # Notice: only frame 0 props will affect Y4M header. 28 | # 29 | # Client side (VS Source mode): 30 | # from TCPClip import Client 31 | # from vapoursynth import core 32 | # clip = Client('', , ).as_source(shutdown=True) 33 | # 34 | # clip.set_output() 35 | # Batches: 36 | # vspipe -y EP01.vpy - | x264 ... --demuxer "y4m" --output "EP01.264" - 37 | # vspipe -y EP02.vpy - | x264 ... --demuxer "y4m" --output "EP02.264" - 38 | # ... 39 | # vspipe -y EP12.vpy - | x264 ... --demuxer "y4m" --output "EP12.264" - 40 | # 41 | # Notice: frame properties will be also copied. 42 | # Notice No.2: If you're previewing your script, set shutdown=False. That will not call shutdown of Server when closing Client. 43 | # Notice No.3: Compression threads are 1 by default, so no threadpool at all. You can set it to 0 and we will use half of script threads or set your own value (min 2 workers). 44 | # 45 | 46 | from vapoursynth import core, VideoNode, VideoFrame # pylint: disable=no-name-in-module 47 | import numpy as np 48 | import socket 49 | from socket import AddressFamily # pylint: disable=no-name-in-module 50 | import sys 51 | import os 52 | import time 53 | import re 54 | import pickle 55 | import signal 56 | import ipaddress 57 | import struct 58 | from threading import Thread 59 | from concurrent.futures import ThreadPoolExecutor 60 | from enum import Enum, IntEnum 61 | from typing import cast, Any, Union, List, Tuple 62 | 63 | try: 64 | from psutil import Process 65 | 66 | def get_usable_cpus_count() -> int: 67 | return len(Process().cpu_affinity()) 68 | except BaseException: 69 | pass 70 | 71 | try: 72 | import lzo 73 | lzo_imported = True 74 | except BaseException: 75 | lzo_imported = False 76 | 77 | 78 | class Version(object): 79 | MAJOR = 2 80 | MINOR = 4 81 | BUGFIX = 0 82 | 83 | 84 | class Action(Enum): 85 | VERSION = 1 86 | CLOSE = 2 87 | EXIT = 3 88 | HEADER = 4 89 | FRAME = 5 90 | 91 | 92 | class LL(IntEnum): 93 | Crit = 1 94 | Warn = 2 95 | Info = 3 96 | Debug = 4 97 | 98 | 99 | class Util(object): 100 | """ Various utilities for Server and Client. """ 101 | 102 | def __new__(cls): 103 | """ Instantiate Utils as Singleton object """ 104 | if not hasattr(cls, 'instance'): 105 | cls.instance = super(Util, cls).__new__(cls) 106 | return cls.instance 107 | 108 | def get_caller(self) -> Union[str, tuple]: 109 | """ Some piece of code to retieve caller class stuff. """ 110 | def stack_(frame): 111 | framelist = [] 112 | while frame: 113 | framelist.append(frame) 114 | frame = frame.f_back 115 | return framelist 116 | stack = stack_(sys._getframe(1)) 117 | if len(stack) < 2: 118 | return 'Main' 119 | parentframe = stack[1] 120 | if 'self' in parentframe.f_locals: 121 | parrent_cls = parentframe.f_locals['self'] 122 | return (parrent_cls.__class__.__name__, parrent_cls.log_level) 123 | return 'Main' 124 | 125 | def as_enum(self, level: str = 'info') -> LL: 126 | """ Cast log level to LL Enum. """ 127 | if isinstance(level, str): 128 | level = { 129 | 'crit': LL.Crit, 130 | 'warn': LL.Warn, 131 | 'info': LL.Info, 132 | 'debug': LL.Debug}.get(level) 133 | return level 134 | 135 | def message(self, level: str, text: str) -> None: 136 | """ Output log message according to log level. """ 137 | facility, parrent_level = self.get_caller() 138 | if self.as_enum(parrent_level) >= Util().as_enum(level): 139 | print(f'{facility:6s} [{level}]: {text}', file=sys.stderr) 140 | 141 | def get_proto_version(self, addr: str) -> AddressFamily: 142 | if addr[0] == '[' and addr[-1] == ']': 143 | addr = addr[1:-1] 144 | version = ipaddress.ip_address(addr).version 145 | return { 146 | 4: socket.AF_INET, 147 | 6: socket.AF_INET6}.get( 148 | version, 149 | socket.AF_INET) 150 | 151 | 152 | class Helper(): 153 | """ Convenient helper for working with socket stuff. """ 154 | 155 | def __init__(self, soc: socket, log_level: Union[str, LL] = None) -> None: 156 | """ Constructor for Helper """ 157 | self.soc = soc 158 | self.log_level = Util().as_enum(log_level) if isinstance( 159 | log_level, str) else log_level 160 | 161 | def send(self, msg: any) -> None: 162 | """ Send data to another endpoint. """ 163 | try: 164 | msg = struct.pack('>I', len(msg)) + msg 165 | self.soc.sendall(msg) 166 | except ConnectionResetError: 167 | Util().message('crit', 'send - interrupted by client.') 168 | 169 | def recv(self) -> bytes: 170 | """ Receive data. """ 171 | try: 172 | raw_msglen = self.recvall(4) 173 | if not raw_msglen: 174 | return None 175 | msglen = struct.unpack('>I', raw_msglen)[0] 176 | return self.recvall(msglen) 177 | except ConnectionResetError: 178 | Util().message('crit', 'recv - interrupted by client.') 179 | 180 | def recvall(self, n: int) -> bytes: 181 | """ Helper method for recv. """ 182 | data = b'' 183 | try: 184 | while len(data) < n: 185 | packet = self.soc.recv(n - len(data)) 186 | if not packet: 187 | return None 188 | data += packet 189 | except ConnectionAbortedError: 190 | Util().message('crit', 'recvall - connection aborted.') 191 | return data 192 | 193 | 194 | class Server(): 195 | """ Server class for serving Vapoursynth's clips. """ 196 | 197 | def __init__(self, 198 | host: str = None, 199 | port: int = 14322, 200 | clip: VideoNode = None, 201 | threads: int = 0, 202 | log_level: Union[str, LL] = 'info', 203 | compression_method: str = None, 204 | compression_level: int = 0, 205 | compression_threads: int = 1) -> None: 206 | """ Constructor for Server. """ 207 | self.log_level = Util().as_enum(log_level) if isinstance( 208 | log_level, str) else log_level 209 | self.compression_method = compression_method 210 | self.compression_level = compression_level 211 | self.compression_threads = compression_threads 212 | if not isinstance(clip, VideoNode): 213 | Util().message('crit', 'argument "clip" has wrong type.') 214 | sys.exit(2) 215 | if self.compression_method != None: 216 | self.compression_method = self.compression_method.lower() 217 | if self.compression_method == 'lzo' and not lzo_imported: 218 | Util().message('warn', 219 | 'compression set to LZO but LZO module is not available. Disabling compression.') 220 | self.compression_method = None 221 | self.threads = core.num_threads if threads == 0 else threads 222 | if self.compression_threads == 0: 223 | self.compression_threads = self.threads // 2 224 | if self.compression_threads != 1: 225 | self.compression_pool = ThreadPoolExecutor( 226 | max_workers=max(self.compression_threads, 2)) 227 | self.clip = clip 228 | self.frame_queue_buffer = dict() 229 | self.cframe_queue_buffer = dict() 230 | self.last_queued_frame = -1 231 | self.client_connected = False 232 | self.soc = socket.socket( 233 | Util().get_proto_version(host), 234 | socket.SOCK_STREAM) 235 | self.soc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 236 | Util().message('info', 'socket created.') 237 | try: 238 | self.soc.bind((host, port)) 239 | Util().message('info', 'socket bind complete.') 240 | except socket.error: 241 | Util().message('crit', f'bind failed. Error: {sys.exc_info()}') 242 | sys.exit(2) 243 | self.soc.listen(2) 244 | Util().message('info', 'listening the socket.') 245 | while True: 246 | conn, addr = self.soc.accept() 247 | ip, port = str(addr[0]), str(addr[1]) 248 | Util().message('info', f'accepting connection from {ip}:{port}.') 249 | try: 250 | if not self.client_connected: 251 | self.conn = conn 252 | self.client_connected = True 253 | Thread(target=self.server_loop, args=(ip, port)).start() 254 | else: 255 | Helper(conn, self.log_level).send(pickle.dumps('busy')) 256 | Util().message('info', 'client already connected, server busy!') 257 | conn.close() 258 | Util().message('info', f'connection {ip}:{port} closed.') 259 | except BaseException: 260 | Util().message( 261 | 'crit', f'can\'t start main server loop! {sys.exc_info()}') 262 | self.soc.close() 263 | 264 | def server_loop(self, ip: str, port: int) -> None: 265 | """ Process client's requests. """ 266 | self.helper = Helper(self.conn, self.log_level) 267 | while True: 268 | input = self.helper.recv() 269 | try: 270 | query = pickle.loads(input) 271 | except BaseException: 272 | query = dict(type=Action.CLOSE) 273 | query_type = query['type'] 274 | if query_type == Action.VERSION: 275 | Util().message('debug', f'requested TCPClip version.') 276 | self.helper.send( 277 | pickle.dumps( 278 | (Version.MAJOR, Version.MINOR, Version.BUGFIX))) 279 | Util().message('debug', f'TCPClip version sent.') 280 | elif query_type == Action.CLOSE: 281 | self.helper.send(pickle.dumps('close')) 282 | for frame in list(self.frame_queue_buffer): 283 | del self.frame_queue_buffer[frame] 284 | Util().message('debug', f'frame {frame} freed.') 285 | self.frame_queue_buffer.clear() 286 | self.conn.close() 287 | self.client_connected = False 288 | Util().message('info', f'connection {ip}:{port} closed.') 289 | return 290 | elif query_type == Action.EXIT: 291 | self.helper.send(pickle.dumps('exit')) 292 | self.conn.close() 293 | self.client_connected = False 294 | Util().message( 295 | 'info', f'connection {ip}:{port} closed. Exiting, as client asked.') 296 | os._exit(0) 297 | return 298 | elif query_type == Action.HEADER: 299 | Util().message('debug', f'requested clip info header.') 300 | self.get_meta() 301 | Util().message('debug', f'clip info header sent.') 302 | elif query_type == Action.FRAME: 303 | Util().message('debug', f'requested frame # {query["frame"]}.') 304 | self.get_frame(query['frame'], query['pipe']) 305 | Util().message('debug', f'frame # {query["frame"]} sent.') 306 | else: 307 | self.conn.close() 308 | self.client_connected = False 309 | Util().message( 310 | 'warn', f'received query has unknown type. Connection {ip}:{port} closed.') 311 | return 312 | 313 | def get_meta(self) -> None: 314 | """ Query clip metadata and send to client. """ 315 | clip = self.clip 316 | props = dict(clip.get_frame(0).props) 317 | self.helper.send( 318 | pickle.dumps( 319 | dict( 320 | format=dict( 321 | id=clip.format.id, 322 | name=clip.format.name, 323 | color_family=int(clip.format.color_family), 324 | sample_type=int(clip.format.sample_type), 325 | bits_per_sample=clip.format.bits_per_sample, 326 | bytes_per_sample=clip.format.bytes_per_sample, 327 | subsampling_w=clip.format.subsampling_w, 328 | subsampling_h=clip.format.subsampling_h, 329 | num_planes=clip.format.num_planes 330 | ), 331 | width=clip.width, 332 | height=clip.height, 333 | num_frames=clip.num_frames, 334 | fps_numerator=clip.fps.numerator, 335 | fps_denominator=clip.fps.denominator, 336 | props=props, 337 | compression_method=self.compression_method 338 | ) 339 | ) 340 | ) 341 | 342 | def execute_parallel_lzo(self, frame: int = 0, pipe: bool = False): 343 | """ Compress frames using LZO method. """ 344 | Util().message( 345 | 'debug', f'execute_parallel_lzo({frame}) called.') 346 | try: 347 | out_frame = self.frame_queue_buffer.pop(frame).result() 348 | except KeyError: 349 | out_frame = self.clip.get_frame_async(frame).result() 350 | self.last_queued_frame = frame 351 | frame_data = [] 352 | for plane in range(out_frame.format.num_planes): 353 | frame_data.append(np.asarray(out_frame.get_read_array(plane))) 354 | frame_data = lzo.compress(pickle.dumps( 355 | frame_data), self.compression_level) 356 | if pipe: 357 | return frame_data 358 | else: 359 | frame_props = dict(out_frame.props) 360 | return frame_data, frame_props 361 | 362 | def get_frame(self, frame: int = 0, pipe: bool = False) -> None: 363 | """ Query arbitrary frame and send it to Client. """ 364 | try: 365 | usable_requests = min(self.threads, get_usable_cpus_count()) 366 | usable_compression_requests = min( 367 | self.compression_threads, get_usable_cpus_count()) 368 | except BaseException: 369 | usable_requests = self.threads 370 | usable_compression_requests = self.compression_threads 371 | for pf in range(min(usable_requests, self.clip.num_frames - frame)): 372 | frame_to_pf = int(frame + pf) 373 | if frame_to_pf not in self.frame_queue_buffer and self.last_queued_frame < frame_to_pf: 374 | self.frame_queue_buffer[frame_to_pf] = self.clip.get_frame_async( 375 | frame_to_pf) 376 | self.last_queued_frame = frame_to_pf 377 | Util().message( 378 | 'debug', f'get_frame_async({frame_to_pf}) called at get_frame({frame}).') 379 | if self.compression_method == 'lzo': 380 | if self.compression_threads != 1: 381 | for cpf in range(min(usable_compression_requests, self.clip.num_frames - frame)): 382 | frame_to_pf = int(frame + cpf) 383 | if frame_to_pf not in self.cframe_queue_buffer: 384 | self.cframe_queue_buffer[frame_to_pf] = self.compression_pool.submit( 385 | self.execute_parallel_lzo, frame_to_pf, pipe) 386 | if pipe: 387 | frame_data = self.cframe_queue_buffer.pop(frame).result() 388 | else: 389 | frame_data, frame_props = self.cframe_queue_buffer.pop( 390 | frame).result() 391 | if self.compression_method == None or self.compression_threads == 1: 392 | try: 393 | out_frame = self.frame_queue_buffer.pop(frame).result() 394 | except KeyError: 395 | out_frame = self.clip.get_frame_async(frame).result() 396 | self.last_queued_frame = frame 397 | frame_data = [np.asarray(out_frame.get_read_array(plane)) for plane in range(out_frame.format.num_planes)] 398 | frame_props = dict(out_frame.props) 399 | if self.compression_method == 'lzo' and self.compression_threads == 1: 400 | frame_data = lzo.compress(pickle.dumps( 401 | frame_data), self.compression_level) 402 | if pipe: 403 | self.helper.send(pickle.dumps(frame_data)) 404 | else: 405 | self.helper.send(pickle.dumps((frame_data, frame_props))) 406 | 407 | 408 | class Client(): 409 | """ Client class for retrieving Vapoursynth clips. """ 410 | 411 | def __init__( 412 | self, 413 | host: str, 414 | port: int = 14322, 415 | log_level: str = 'info', 416 | shutdown: bool = False) -> None: 417 | """ Constructor for Client. """ 418 | self.log_level = Util().as_enum(log_level) if isinstance( 419 | log_level, str) else log_level 420 | self.shutdown = shutdown 421 | self.compression_method = None 422 | self._stop = False # workaround for early interrupt 423 | try: 424 | self.soc = socket.socket( 425 | Util().get_proto_version(host), 426 | socket.SOCK_STREAM) 427 | self.soc.connect((host, port)) 428 | self.helper = Helper(self.soc, self.log_level) 429 | except ConnectionRefusedError: 430 | Util().message( 431 | 'crit', 432 | 'connection time-out reached. Probably closed port or server is down.') 433 | sys.exit(2) 434 | 435 | def __del__(self) -> None: 436 | """ Destructor for Client. """ 437 | if self.shutdown: # kill server on exit 438 | self.exit() 439 | 440 | def query(self, data: dict) -> Any: 441 | """ Handle arbitrary queries via single method. """ 442 | try: 443 | self.helper.send(pickle.dumps(data)) 444 | answer = pickle.loads(self.helper.recv()) 445 | if answer == "busy": 446 | Util().message('crit', f'server is busy.') 447 | sys.exit(2) 448 | return answer 449 | except BaseException: 450 | Util().message('crit', f'failed to make query {data}.') 451 | sys.exit(2) 452 | 453 | def version(self, minor: bool = False) -> Union[tuple, int]: 454 | """ Wrapper for requesting Server's version. """ 455 | v = self.query(dict(type=Action.VERSION)) 456 | if minor: 457 | return v 458 | else: 459 | return v[0] 460 | 461 | def close(self) -> None: 462 | """ Wrapper for terminating Client's connection. """ 463 | self.query(dict(type=Action.CLOSE)) 464 | self.soc.close() 465 | 466 | def exit(self, code: int = 0) -> None: 467 | """ Wrapper for terminating Client and Server at once. """ 468 | try: 469 | self.query(dict(type=Action.EXIT)) 470 | self.soc.close() 471 | sys.exit(code) 472 | except BaseException: 473 | pass 474 | 475 | def get_meta(self) -> dict: 476 | """ Wrapper for requesting clip's info. """ 477 | meta = self.query(dict(type=Action.HEADER)) 478 | if meta['compression_method'] == 'lzo': 479 | if not lzo_imported: 480 | raise ValueError( 481 | 'got LZO compression from the Server but we can\'t decompress that since no LZO module loaded. Unable to continue.') 482 | else: 483 | self.compression_method = meta['compression_method'] 484 | return meta 485 | 486 | def get_frame(self, frame: int, 487 | pipe: bool = False) -> Union[Tuple[list, dict], list]: 488 | """ Wrapper for requesting arbitrary frame from the Server. """ 489 | return self.query(dict(type=Action.FRAME, frame=frame, pipe=pipe)) 490 | 491 | def get_y4m_csp(self, clip_format: dict) -> str: 492 | """ Colorspace string builder. """ 493 | if clip_format['bits_per_sample'] > 16: 494 | Util().message('crit', 'only 8-16 bit YUV or Gray formats are supported for Y4M outputs.') 495 | self.exit(2) 496 | bits = clip_format['bits_per_sample'] 497 | if clip_format['num_planes'] == 3: 498 | y = 4 499 | w = y >> clip_format['subsampling_w'] 500 | h = y >> clip_format['subsampling_h'] 501 | u = abs(w) 502 | v = abs(y - w - h) 503 | csp = f'{y}{u}{v}' 504 | else: 505 | csp = None 506 | return {1: f'Cmono{bits}', 3: f'C{csp}p{bits}'}.get( 507 | clip_format['num_planes'], 'C420p8') 508 | 509 | def sigint_handler(self, *args) -> None: 510 | """ Handle "to_stdout()"'s cancelation. """ 511 | self._stop = True 512 | 513 | def to_stdout(self) -> None: 514 | """ Pipe frames via stdout. """ 515 | if self.log_level >= LL.Info: 516 | start = time.perf_counter() 517 | server_version = self.version() 518 | if server_version != Version.MAJOR: 519 | Util().message( 520 | 'crit', 521 | f'version mismatch!\nServer: {server_version} | Client: {Version.MAJOR}') 522 | self.exit(2) 523 | header_info = self.get_meta() 524 | if len(header_info) == 0: 525 | Util().message('crit', 'wrong header info.') 526 | self.exit(2) 527 | if 'format' in header_info: 528 | clip_format = header_info['format'] 529 | else: 530 | Util().message('crit', 'missing "Format".') 531 | self.exit(2) 532 | if 'props' in header_info: 533 | props = header_info['props'] 534 | else: 535 | Util().message('crit', 'missing "props".') 536 | self.exit(2) 537 | if '_FieldBased' in props: 538 | frameType = {2: 't', 1: 'b', 0: 'p'}.get(props['_FieldBased'], 'p') 539 | else: 540 | frameType = 'p' 541 | if '_SARNum' and '_SARDen' in props: 542 | sar_num, sar_den = props['_SARNum'], props['_SARDen'] 543 | else: 544 | sar_num, sar_den = 0, 0 545 | num_frames = header_info['num_frames'] 546 | width = header_info['width'] 547 | height = header_info['height'] 548 | fps_num = header_info['fps_numerator'] 549 | fps_den = header_info['fps_denominator'] 550 | csp = self.get_y4m_csp(clip_format) 551 | sys.stdout.buffer.write( 552 | bytes( 553 | f'YUV4MPEG2 W{width} H{height} F{fps_num}:{fps_den} I{frameType} A{sar_num}:{sar_den} {csp} XYSCSS={csp} XLENGTH={num_frames}\n', 554 | 'UTF-8')) 555 | signal.signal(signal.SIGINT, self.sigint_handler) 556 | for frame_number in range(num_frames): 557 | if self._stop: 558 | break 559 | if self.log_level >= LL.Info: 560 | frameTime = time.perf_counter() 561 | eta = (frameTime - start) * (num_frames - 562 | (frame_number + 1)) / ((frame_number + 1)) 563 | frame_data = self.get_frame(frame_number, pipe=True) 564 | if self.compression_method == 'lzo': 565 | frame_data = pickle.loads(lzo.decompress(frame_data)) 566 | sys.stdout.buffer.write(bytes('FRAME\n', 'UTF-8')) 567 | for plane in frame_data: 568 | sys.stdout.buffer.write(plane) 569 | if self.log_level >= LL.Info: 570 | sys.stderr.write( 571 | f'Processing {frame_number}/{num_frames} ({frame_number/frameTime:.003f} fps) [{float(100 * frame_number / num_frames):.1f} %] [ETA: {int(eta//3600):d}:{int((eta//60)%60):02d}:{int(eta%60):02d}] \r') 572 | 573 | def as_source(self) -> VideoNode: 574 | """ Expose Client as source filter for Vapoursynth. """ 575 | def frame_copy(n: int, f: VideoFrame) -> VideoFrame: 576 | fout = f.copy() 577 | frame_data, frame_props = self.get_frame(n, pipe=False) 578 | if self.compression_method == 'lzo': 579 | frame_data = pickle.loads(lzo.decompress(frame_data)) 580 | for p in range(fout.format.num_planes): 581 | np.asarray(fout.get_write_array(p))[:] = frame_data[p] 582 | for i in frame_props: 583 | fout.props[i] = frame_props[i] 584 | return fout 585 | server_version = self.version() 586 | assert server_version == Version.MAJOR, f'Version mismatch!\nServer: {server_version} | Client: {Version.MAJOR}' 587 | header_info = self.get_meta() 588 | assert len(header_info) > 0, 'Wrong header info.' 589 | assert 'format' in header_info, 'Missing "Format".' 590 | clip_format = header_info['format'] 591 | source_format = core.register_format( 592 | clip_format['color_family'], 593 | clip_format['sample_type'], 594 | clip_format['bits_per_sample'], 595 | clip_format['subsampling_w'], 596 | clip_format['subsampling_h']) 597 | dummy = core.std.BlankClip( 598 | width=header_info['width'], 599 | height=header_info['height'], 600 | format=source_format, 601 | length=header_info['num_frames'], 602 | fpsnum=header_info['fps_numerator'], 603 | fpsden=header_info['fps_denominator'], 604 | keep=True) 605 | source = core.std.ModifyFrame(dummy, dummy, frame_copy) 606 | return source 607 | --------------------------------------------------------------------------------