feat(harmony): add H.264 companion development pipeline
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure the companion's HCS1 video stream; no screenshots/files are retained.
|
||||
|
||||
Development tool, not the plugin's production player. Requires official HDC,
|
||||
the signed companion on the phone, and ffmpeg (or ffplay with --view).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
BUNDLE = "ink.render.trove.harmonyconnector"
|
||||
MAX_PACKET = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def read_exact(sock, size, deadline):
|
||||
result = bytearray()
|
||||
while len(result) < size:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("Video stream timed out")
|
||||
sock.settimeout(min(remaining, 0.2))
|
||||
try:
|
||||
part = sock.recv(size - len(result))
|
||||
except socket.timeout:
|
||||
continue
|
||||
if not part:
|
||||
raise EOFError("Screen sharing stopped or USB disconnected")
|
||||
result.extend(part)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def read_hello(sock, deadline):
|
||||
magic, width, height, display_width, display_height, fps = struct.unpack(
|
||||
">4s5I", read_exact(sock, 24, deadline))
|
||||
if (magic != b"HCS1" or not 1 <= fps <= 60 or
|
||||
not all(2 <= d <= 8192 for d in (width, height, display_width, display_height)) or
|
||||
width % 2 or height % 2 or max(width, height) > 1280):
|
||||
raise ValueError("Unsupported stream header")
|
||||
return {"width": width, "height": height, "displayWidth": display_width,
|
||||
"displayHeight": display_height, "configuredFps": fps}
|
||||
|
||||
|
||||
def read_packet(sock, deadline):
|
||||
size, flags, pts = struct.unpack(
|
||||
">IIq", read_exact(sock, 16, deadline))
|
||||
if not 0 < size <= MAX_PACKET or flags & ~0x7f or pts < 0:
|
||||
raise ValueError("Invalid video packet")
|
||||
data = read_exact(sock, size, deadline)
|
||||
if not data.startswith((b"\0\0\1", b"\0\0\0\1")):
|
||||
raise ValueError("Encoder did not return Annex B H.264")
|
||||
return flags, pts, data
|
||||
|
||||
|
||||
class Decoder:
|
||||
def __init__(self, view):
|
||||
name = "ffplay" if view else "ffmpeg"
|
||||
executable = shutil.which(name)
|
||||
if not executable:
|
||||
raise RuntimeError(f"Development measurement requires {name} on PATH")
|
||||
args = [executable, "-hide_banner", "-loglevel", "error",
|
||||
"-flags", "low_delay", "-probesize", "32", "-analyzeduration", "0",
|
||||
"-f", "h264", "-i", "pipe:0"]
|
||||
if view:
|
||||
args += ["-window_title", "Harmony Connector · H.264 preview", "-autoexit"]
|
||||
else:
|
||||
args += ["-progress", "pipe:1", "-stats_period", "0.25", "-f", "null", "-"]
|
||||
self.frames = 0
|
||||
self.error = bytearray()
|
||||
self.process = subprocess.Popen(args, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
os.set_blocking(self.process.stdin.fileno(), False)
|
||||
self.threads = [threading.Thread(target=self.progress, daemon=True),
|
||||
threading.Thread(target=self.errors, daemon=True)]
|
||||
for thread in self.threads:
|
||||
thread.start()
|
||||
|
||||
def progress(self):
|
||||
for line in self.process.stdout:
|
||||
if line.startswith(b"frame="):
|
||||
self.frames = int(line.split(b"=", 1)[1])
|
||||
|
||||
def errors(self):
|
||||
for line in self.process.stderr:
|
||||
if len(self.error) < 8192:
|
||||
self.error.extend(line[:8192 - len(self.error)])
|
||||
|
||||
def write(self, data):
|
||||
deadline = time.monotonic() + 1
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError("Video decoder exited: " + self.error.decode(errors="replace"))
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Decoder is falling behind; stopped instead of queuing video")
|
||||
if not select.select([], [self.process.stdin], [], 0.05)[1]:
|
||||
continue
|
||||
try:
|
||||
count = os.write(self.process.stdin.fileno(), view)
|
||||
view = view[count:]
|
||||
except BlockingIOError:
|
||||
continue
|
||||
|
||||
def close(self):
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
self.process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
for thread in self.threads:
|
||||
thread.join(timeout=1)
|
||||
self.process.stdout.close()
|
||||
self.process.stderr.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--hdc", required=True, type=Path)
|
||||
parser.add_argument("--target")
|
||||
parser.add_argument("--seconds", type=int, default=20)
|
||||
parser.add_argument("--view", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not 5 <= args.seconds <= 120 or not args.hdc.is_file():
|
||||
parser.error("Use an existing HDC executable and 5–120 measurement seconds")
|
||||
|
||||
def hdc(*command):
|
||||
try:
|
||||
result = subprocess.run([str(args.hdc.resolve()), *command], capture_output=True,
|
||||
text=True, timeout=10, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("HDC command timed out") from None
|
||||
if result.returncode:
|
||||
raise RuntimeError("HDC command did not complete successfully")
|
||||
if re.search(r"\b(fail(?:ed)?|error)\b", result.stdout + result.stderr, re.I):
|
||||
raise RuntimeError("HDC command failed: " + result.stdout + result.stderr)
|
||||
return result.stdout
|
||||
|
||||
targets = [line.split()[0] for line in hdc("list", "targets", "-v").splitlines()
|
||||
if "Connected" in line.split()]
|
||||
target = args.target or (targets[0] if len(targets) == 1 else None)
|
||||
if target not in targets:
|
||||
raise RuntimeError("Select one connected, authorized phone with --target")
|
||||
installed = hdc("-t", target, "shell", f"bm dump -n {BUNDLE}")
|
||||
if BUNDLE not in installed:
|
||||
raise RuntimeError("Install the signed Harmony Connector companion before measuring")
|
||||
decoder = Decoder(args.view)
|
||||
sock = None
|
||||
forward = False
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(("127.0.0.1", 0))
|
||||
local_port = reservation.getsockname()[1]
|
||||
remote_port = 20000 + secrets.randbelow(40000)
|
||||
local, remote = f"tcp:{local_port}", f"tcp:{remote_port}"
|
||||
token = secrets.token_hex(32)
|
||||
frames = total_bytes = 0
|
||||
started = first_frame = None
|
||||
try:
|
||||
hdc("-t", target, "fport", local, remote)
|
||||
forward = True
|
||||
hdc("-t", target, "shell", f"aa start -b {BUNDLE} -a EntryAbility -m entry "
|
||||
f"--ps token {token} --pi port {remote_port}")
|
||||
print("请在手机点击「共享屏幕」,并同意系统授权。", flush=True)
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
sock = socket.create_connection(("127.0.0.1", local_port), timeout=1)
|
||||
sock.sendall(token.encode("ascii"))
|
||||
hello = read_hello(sock, min(deadline, time.monotonic() + 2))
|
||||
break
|
||||
except (OSError, EOFError, TimeoutError):
|
||||
if sock:
|
||||
sock.close()
|
||||
sock = None
|
||||
time.sleep(0.2)
|
||||
if not sock:
|
||||
raise TimeoutError("Phone did not start screen sharing")
|
||||
print(json.dumps({"stage": "connected", **hello}), flush=True)
|
||||
started = time.monotonic()
|
||||
last_report = started
|
||||
while first_frame is None or time.monotonic() - first_frame < args.seconds:
|
||||
flags, _, data = read_packet(sock, time.monotonic() + (45 if first_frame is None else 3))
|
||||
decoder.write(data)
|
||||
total_bytes += len(data)
|
||||
now = time.monotonic()
|
||||
if not flags & 8:
|
||||
frames += 1
|
||||
if first_frame is None:
|
||||
first_frame = now
|
||||
print(json.dumps({"stage": "first-frame", "waitMs": round((now - started) * 1000)}), flush=True)
|
||||
if first_frame and now - last_report >= 1:
|
||||
elapsed = now - first_frame
|
||||
print(json.dumps({"stage": "receiving", "frames": frames,
|
||||
"receivedFps": round((frames - 1) / elapsed, 1),
|
||||
"decodedFrames": decoder.frames,
|
||||
"mbps": round(total_bytes * 8 / elapsed / 1000000, 2)}), flush=True)
|
||||
last_report = now
|
||||
elapsed = time.monotonic() - first_frame
|
||||
finally:
|
||||
if sock:
|
||||
try:
|
||||
sock.sendall(b"\0")
|
||||
except OSError:
|
||||
pass
|
||||
sock.close()
|
||||
try:
|
||||
if forward:
|
||||
hdc("-t", target, "fport", "rm", local, remote)
|
||||
finally:
|
||||
decoder.close()
|
||||
passed = (frames - 1) / elapsed >= 20 and (args.view or decoder.frames >= frames * 0.95)
|
||||
print(json.dumps({"passed": passed, "frames": frames, "decodedFrames": decoder.frames,
|
||||
"receivedFps": round((frames - 1) / elapsed, 1), "durationSeconds": round(elapsed, 2),
|
||||
"endToEndLatencyMeasured": False, "inputSent": False, "screenFilesSaved": False}), flush=True)
|
||||
if not passed:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user