fluxcast 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.
- _fluxcast_data/fluxcast.desktop +9 -0
- _fluxcast_data/zz-dev.fluxcast.wpa-supplicant.conf +14 -0
- assets/flcast_logo_512x512.png +0 -0
- capture.py +564 -0
- cast.py +93 -0
- diagnostics.py +489 -0
- dlna.py +127 -0
- drivers/.gitignore +3 -0
- drivers/__init__.py +0 -0
- drivers/test_lpcm_mux_loopback.py +237 -0
- drivers/test_pcr_jitter.py +300 -0
- drivers/wfd_lpcm_mux.py +588 -0
- fluxcast-0.1.0.dist-info/METADATA +246 -0
- fluxcast-0.1.0.dist-info/RECORD +23 -0
- fluxcast-0.1.0.dist-info/WHEEL +4 -0
- fluxcast-0.1.0.dist-info/entry_points.txt +3 -0
- fluxcast-0.1.0.dist-info/licenses/LICENSE +674 -0
- main.py +351 -0
- portal_capture.py +450 -0
- pypi_sysinstall.py +203 -0
- server.py +715 -0
- tray.py +532 -0
- wfd.py +3100 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE busconfig PUBLIC
|
|
2
|
+
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
|
3
|
+
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
|
4
|
+
<busconfig>
|
|
5
|
+
<policy context="default">
|
|
6
|
+
<allow send_destination="fi.w1.wpa_supplicant1"
|
|
7
|
+
send_interface="org.freedesktop.DBus.Properties"
|
|
8
|
+
send_member="Get"/>
|
|
9
|
+
<allow send_destination="fi.w1.wpa_supplicant1"
|
|
10
|
+
send_interface="org.freedesktop.DBus.Properties"
|
|
11
|
+
send_member="Set"/>
|
|
12
|
+
<allow receive_sender="fi.w1.wpa_supplicant1"/>
|
|
13
|
+
</policy>
|
|
14
|
+
</busconfig>
|
|
Binary file
|
capture.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional, NamedTuple
|
|
9
|
+
|
|
10
|
+
FFMPEG_PATH = shutil.which("ffmpeg") or "/usr/sbin/ffmpeg"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Monitor(NamedTuple):
|
|
14
|
+
display: str
|
|
15
|
+
name: str # e.g. 'eDP-1'
|
|
16
|
+
width: int
|
|
17
|
+
height: int
|
|
18
|
+
x: int # x offset within the combined framebuffer
|
|
19
|
+
y: int
|
|
20
|
+
refresh: float # Hz
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionInfo(NamedTuple):
|
|
24
|
+
session_type: str
|
|
25
|
+
desktop: str
|
|
26
|
+
wm: str
|
|
27
|
+
is_hyprland: bool
|
|
28
|
+
is_wayland: bool
|
|
29
|
+
is_x11: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CaptureStartError(RuntimeError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CaptureResult(NamedTuple):
|
|
37
|
+
process: "subprocess.Popen[bytes]"
|
|
38
|
+
aux_process: "Optional[subprocess.Popen[bytes]]" = None
|
|
39
|
+
portal_session: Any = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_session() -> SessionInfo:
|
|
43
|
+
session_type = (os.environ.get("XDG_SESSION_TYPE") or "").strip().lower()
|
|
44
|
+
desktop = (os.environ.get("XDG_CURRENT_DESKTOP") or "").strip()
|
|
45
|
+
wm = (os.environ.get("XDG_SESSION_DESKTOP") or "").strip()
|
|
46
|
+
hypr_sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
|
47
|
+
is_hyprland = bool(hypr_sig) or "hyprland" in desktop.lower() or "hyprland" in wm.lower()
|
|
48
|
+
is_wayland = bool(os.environ.get("WAYLAND_DISPLAY")) or session_type == "wayland"
|
|
49
|
+
is_x11 = bool(os.environ.get("DISPLAY")) and (session_type == "x11" or not is_wayland)
|
|
50
|
+
if not session_type:
|
|
51
|
+
session_type = "wayland" if is_wayland else ("x11" if is_x11 else "unknown")
|
|
52
|
+
return SessionInfo(
|
|
53
|
+
session_type=session_type,
|
|
54
|
+
desktop=desktop or "unknown",
|
|
55
|
+
wm=wm or "unknown",
|
|
56
|
+
is_hyprland=is_hyprland,
|
|
57
|
+
is_wayland=is_wayland,
|
|
58
|
+
is_x11=is_x11,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _default_audio_monitor() -> str:
|
|
63
|
+
pactl = shutil.which("pactl")
|
|
64
|
+
if not pactl:
|
|
65
|
+
return "default"
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
[pactl, "get-default-sink"],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
timeout=2.0,
|
|
72
|
+
)
|
|
73
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
74
|
+
return "default"
|
|
75
|
+
sink = result.stdout.strip()
|
|
76
|
+
if sink:
|
|
77
|
+
return sink + ".monitor"
|
|
78
|
+
return "default"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _gst_has_element(name: str) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["gst-inspect-1.0", name],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
timeout=10.0,
|
|
87
|
+
)
|
|
88
|
+
return result.returncode == 0
|
|
89
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _auto_capture_backend_order() -> list[str]:
|
|
94
|
+
session = detect_session()
|
|
95
|
+
if session.is_hyprland:
|
|
96
|
+
return ["wf-recorder", "x11grab"]
|
|
97
|
+
if session.is_x11:
|
|
98
|
+
return ["x11grab", "wf-recorder"]
|
|
99
|
+
if session.is_wayland:
|
|
100
|
+
return ["portal", "wf-recorder"]
|
|
101
|
+
return ["x11grab", "wf-recorder"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def choose_capture_backend(preferred: str = "auto") -> str:
|
|
105
|
+
if preferred != "auto":
|
|
106
|
+
return preferred
|
|
107
|
+
return _auto_capture_backend_order()[0]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def describe_capture_selection(backend: str) -> None:
|
|
111
|
+
session = detect_session()
|
|
112
|
+
print(
|
|
113
|
+
"[FluxCast] Session detected: "
|
|
114
|
+
f"type={session.session_type}, desktop={session.desktop}, wm={session.wm}"
|
|
115
|
+
)
|
|
116
|
+
if backend == "portal":
|
|
117
|
+
print("[FluxCast] Capture backend: XDG portal (KDE/GNOME Wayland)")
|
|
118
|
+
elif backend == "wf-recorder" and session.is_wayland and not session.is_hyprland:
|
|
119
|
+
print(
|
|
120
|
+
"[FluxCast] Capture backend: wf-recorder (best-effort on this Wayland session). "
|
|
121
|
+
"If capture fails, use an X11 session or install portal stack for KDE/GNOME."
|
|
122
|
+
)
|
|
123
|
+
elif backend == "x11grab" and session.is_wayland:
|
|
124
|
+
print(
|
|
125
|
+
"[FluxCast] Capture backend: x11grab (forced). "
|
|
126
|
+
"On Wayland this may produce a black stream."
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
print(f"[FluxCast] Capture backend: {backend}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _detect_live_displays() -> list:
|
|
133
|
+
locks = glob.glob("/tmp/.X*-lock")
|
|
134
|
+
displays = []
|
|
135
|
+
for lock in sorted(locks):
|
|
136
|
+
m = re.search(r"/\.X(\d+)-lock$", lock)
|
|
137
|
+
if m:
|
|
138
|
+
displays.append(f":{m.group(1)}")
|
|
139
|
+
return displays or [":0"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_xrandr(display: str) -> list:
|
|
143
|
+
xrandr = shutil.which("xrandr")
|
|
144
|
+
if not xrandr:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
env = os.environ.copy()
|
|
148
|
+
env["DISPLAY"] = display
|
|
149
|
+
try:
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
[xrandr, "--query"],
|
|
152
|
+
capture_output=True, text=True, timeout=4, env=env,
|
|
153
|
+
)
|
|
154
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
monitors: list = []
|
|
161
|
+
lines = result.stdout.splitlines()
|
|
162
|
+
for i, line in enumerate(lines):
|
|
163
|
+
m = re.match(
|
|
164
|
+
r"^(\S+)\s+connected\s+(?:primary\s+)?(\d+)x(\d+)\+(\d+)\+(\d+)",
|
|
165
|
+
line,
|
|
166
|
+
)
|
|
167
|
+
if not m:
|
|
168
|
+
continue
|
|
169
|
+
name, w, h, x, y = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5))
|
|
170
|
+
refresh = 0.0
|
|
171
|
+
if i + 1 < len(lines):
|
|
172
|
+
rm = re.search(r"([\d.]+)\*", lines[i + 1])
|
|
173
|
+
if rm:
|
|
174
|
+
refresh = float(rm.group(1))
|
|
175
|
+
monitors.append(Monitor(
|
|
176
|
+
display=display, name=name,
|
|
177
|
+
width=w, height=h, x=x, y=y, refresh=refresh,
|
|
178
|
+
))
|
|
179
|
+
return monitors
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def gather_monitors() -> list:
|
|
183
|
+
all_monitors: list = []
|
|
184
|
+
for disp in _detect_live_displays():
|
|
185
|
+
all_monitors.extend(_parse_xrandr(disp))
|
|
186
|
+
return all_monitors
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def prompt_monitor() -> Monitor:
|
|
190
|
+
monitors = gather_monitors()
|
|
191
|
+
|
|
192
|
+
if not monitors:
|
|
193
|
+
print("[FluxCast] WARNING: xrandr returned no monitors. Falling back to :0 1920x1080.")
|
|
194
|
+
return Monitor(display=":0", name="(unknown)", width=1920, height=1080,
|
|
195
|
+
x=0, y=0, refresh=60.0)
|
|
196
|
+
|
|
197
|
+
current_display = os.environ.get("DISPLAY", "")
|
|
198
|
+
|
|
199
|
+
print("\n[FluxCast] Available monitors to capture:")
|
|
200
|
+
print(f" {'#':<4} {'Monitor':<12} {'Display':<8} "
|
|
201
|
+
f"{'Resolution':<14} {'Position':<14} {'Refresh'}")
|
|
202
|
+
print(f" {'-'*4} {'-'*12} {'-'*8} {'-'*14} {'-'*14} {'-'*8}")
|
|
203
|
+
|
|
204
|
+
default_idx = 0
|
|
205
|
+
for i, mon in enumerate(monitors):
|
|
206
|
+
pos = f"{mon.x},{mon.y}"
|
|
207
|
+
hz = f"{mon.refresh:.1f} Hz" if mon.refresh else "—"
|
|
208
|
+
current = " ← active" if mon.display == current_display and i == 0 else ""
|
|
209
|
+
print(f" [{i}] {mon.name:<12} {mon.display:<8} "
|
|
210
|
+
f"{mon.width}x{mon.height:<8} {pos:<14} {hz}{current}")
|
|
211
|
+
if mon.display == current_display:
|
|
212
|
+
default_idx = i
|
|
213
|
+
|
|
214
|
+
print()
|
|
215
|
+
raw = input(f"Select monitor [{default_idx}]: ").strip()
|
|
216
|
+
if raw == "":
|
|
217
|
+
return monitors[default_idx]
|
|
218
|
+
try:
|
|
219
|
+
idx = int(raw)
|
|
220
|
+
return monitors[idx]
|
|
221
|
+
except (ValueError, IndexError):
|
|
222
|
+
print(f"[FluxCast] Invalid choice, using monitor {default_idx}.")
|
|
223
|
+
return monitors[default_idx]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def start_capture(
|
|
227
|
+
monitor: Monitor,
|
|
228
|
+
fps: int = 30,
|
|
229
|
+
bitrate: str = "4M",
|
|
230
|
+
output_resolution: Optional[str] = None,
|
|
231
|
+
backend: str = "auto",
|
|
232
|
+
) -> "CaptureResult":
|
|
233
|
+
backends = [backend] if backend != "auto" else _auto_capture_backend_order()
|
|
234
|
+
errors: list[str] = []
|
|
235
|
+
|
|
236
|
+
for idx, candidate in enumerate(backends):
|
|
237
|
+
describe_capture_selection(candidate)
|
|
238
|
+
try:
|
|
239
|
+
if candidate == "portal":
|
|
240
|
+
return _start_capture_portal(monitor, fps, bitrate, output_resolution)
|
|
241
|
+
if candidate == "x11grab":
|
|
242
|
+
proc = _start_capture_x11grab(monitor, fps, bitrate, output_resolution)
|
|
243
|
+
else:
|
|
244
|
+
proc = _start_capture_wf_recorder(monitor, fps, bitrate, output_resolution)
|
|
245
|
+
return CaptureResult(process=proc)
|
|
246
|
+
except CaptureStartError as exc:
|
|
247
|
+
errors.append(f"{candidate}: {exc}")
|
|
248
|
+
if idx < len(backends) - 1:
|
|
249
|
+
print(f"[FluxCast] Capture backend {candidate} failed, trying fallback...")
|
|
250
|
+
|
|
251
|
+
detail = "; ".join(errors) if errors else "unknown capture error"
|
|
252
|
+
print(f"[FluxCast] ERROR: Could not start capture backend ({detail})")
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _start_capture_wf_recorder(
|
|
257
|
+
monitor: Monitor,
|
|
258
|
+
fps: int = 30,
|
|
259
|
+
bitrate: str = "4M",
|
|
260
|
+
output_resolution: Optional[str] = None,
|
|
261
|
+
) -> "subprocess.Popen[bytes]":
|
|
262
|
+
if not shutil.which("wf-recorder"):
|
|
263
|
+
raise CaptureStartError("wf-recorder not found in PATH")
|
|
264
|
+
src_res = f"{monitor.width}x{monitor.height}"
|
|
265
|
+
|
|
266
|
+
if output_resolution:
|
|
267
|
+
ow, oh = output_resolution.lower().split("x")
|
|
268
|
+
out_w, out_h = int(ow), int(oh)
|
|
269
|
+
else:
|
|
270
|
+
out_w, out_h = 1920, 1080
|
|
271
|
+
|
|
272
|
+
audio_monitor = _default_audio_monitor()
|
|
273
|
+
|
|
274
|
+
cmd = [
|
|
275
|
+
"wf-recorder",
|
|
276
|
+
"-y",
|
|
277
|
+
"-D",
|
|
278
|
+
"-r", str(fps),
|
|
279
|
+
f"--audio={audio_monitor}",
|
|
280
|
+
"-C", "aac",
|
|
281
|
+
"-P", "b:a=128k",
|
|
282
|
+
"-c", "libx264",
|
|
283
|
+
"-m", "hls",
|
|
284
|
+
# scale to target res and force SAR=1:1 so the TV sees 16:9, not 16:10
|
|
285
|
+
"-F", f"scale={out_w}:{out_h},setsar=1",
|
|
286
|
+
"-p", "preset=ultrafast",
|
|
287
|
+
"-p", "tune=zerolatency",
|
|
288
|
+
"-p", "hls_time=1",
|
|
289
|
+
"-p", "hls_list_size=3",
|
|
290
|
+
"-p", "hls_flags=append_list",
|
|
291
|
+
"-p", "pix_fmt=yuv420p",
|
|
292
|
+
"-p", "profile=main",
|
|
293
|
+
"-f", "/tmp/fluxcast/stream.m3u8",
|
|
294
|
+
"-o", monitor.name,
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
print(f"[FluxCast] Capturing Wayland monitor : {monitor.name} ({src_res})")
|
|
298
|
+
print(f"[FluxCast] Output: {out_w}x{out_h}, audio: {audio_monitor}")
|
|
299
|
+
|
|
300
|
+
hls_dir = "/tmp/fluxcast"
|
|
301
|
+
if os.path.exists(hls_dir):
|
|
302
|
+
shutil.rmtree(hls_dir)
|
|
303
|
+
os.makedirs(hls_dir, exist_ok=True)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
process = subprocess.Popen(
|
|
307
|
+
cmd,
|
|
308
|
+
stdout=subprocess.DEVNULL,
|
|
309
|
+
stderr=subprocess.DEVNULL,
|
|
310
|
+
)
|
|
311
|
+
except FileNotFoundError as e:
|
|
312
|
+
raise CaptureStartError(f"required executable not found: {e}") from e
|
|
313
|
+
|
|
314
|
+
time.sleep(1.5)
|
|
315
|
+
if process.poll() is not None:
|
|
316
|
+
raise CaptureStartError("wf-recorder exited immediately")
|
|
317
|
+
|
|
318
|
+
return process
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def stop_capture(result: "Optional[CaptureResult]") -> None:
|
|
322
|
+
if result is None:
|
|
323
|
+
return
|
|
324
|
+
for proc in (result.process, result.aux_process):
|
|
325
|
+
if proc is None or proc.poll() is not None:
|
|
326
|
+
continue
|
|
327
|
+
proc.terminate()
|
|
328
|
+
try:
|
|
329
|
+
proc.wait(timeout=5)
|
|
330
|
+
except subprocess.TimeoutExpired:
|
|
331
|
+
proc.kill()
|
|
332
|
+
if result.portal_session is not None:
|
|
333
|
+
from portal_capture import close_portal_capture
|
|
334
|
+
close_portal_capture(result.portal_session)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _start_capture_portal(
|
|
338
|
+
monitor: Monitor,
|
|
339
|
+
fps: int = 30,
|
|
340
|
+
bitrate: str = "4M",
|
|
341
|
+
output_resolution: Optional[str] = None,
|
|
342
|
+
) -> "CaptureResult":
|
|
343
|
+
if not shutil.which("gst-launch-1.0"):
|
|
344
|
+
raise CaptureStartError("portal backend requires gst-launch-1.0")
|
|
345
|
+
|
|
346
|
+
required = ("pipewiresrc", "videoconvert", "videoscale", "videorate",
|
|
347
|
+
"x264enc", "h264parse", "hlssink2")
|
|
348
|
+
missing = [e for e in required if not _gst_has_element(e)]
|
|
349
|
+
if missing:
|
|
350
|
+
raise CaptureStartError(
|
|
351
|
+
"portal backend requires missing GStreamer elements: " + ", ".join(missing)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
from portal_capture import (
|
|
355
|
+
PortalCaptureError,
|
|
356
|
+
close_portal_capture,
|
|
357
|
+
start_portal_capture,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
b = bitrate.upper().rstrip("B")
|
|
361
|
+
if b.endswith("M"):
|
|
362
|
+
bitrate_kbits = int(float(b[:-1]) * 1000)
|
|
363
|
+
elif b.endswith("K"):
|
|
364
|
+
bitrate_kbits = int(float(b[:-1]))
|
|
365
|
+
else:
|
|
366
|
+
bitrate_kbits = max(1, int(b) // 1000)
|
|
367
|
+
|
|
368
|
+
out_w, out_h = monitor.width, monitor.height
|
|
369
|
+
if output_resolution:
|
|
370
|
+
try:
|
|
371
|
+
ow, oh = output_resolution.lower().split("x")
|
|
372
|
+
out_w, out_h = int(ow), int(oh)
|
|
373
|
+
except (ValueError, AttributeError):
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
hls_dir = "/tmp/fluxcast"
|
|
377
|
+
if os.path.exists(hls_dir):
|
|
378
|
+
shutil.rmtree(hls_dir)
|
|
379
|
+
os.makedirs(hls_dir, exist_ok=True)
|
|
380
|
+
|
|
381
|
+
audio_monitor = _default_audio_monitor()
|
|
382
|
+
audio_enc = (
|
|
383
|
+
"avenc_aac" if _gst_has_element("avenc_aac") else
|
|
384
|
+
"faac" if _gst_has_element("faac") else None
|
|
385
|
+
)
|
|
386
|
+
has_audio = audio_enc is not None and _gst_has_element("pulsesrc")
|
|
387
|
+
|
|
388
|
+
print("[FluxCast] Opening portal screen-share dialog (KDE/GNOME Wayland)...")
|
|
389
|
+
try:
|
|
390
|
+
session = start_portal_capture(
|
|
391
|
+
timeout=120.0,
|
|
392
|
+
preferred_position=(monitor.x, monitor.y),
|
|
393
|
+
preferred_size=(monitor.width, monitor.height),
|
|
394
|
+
)
|
|
395
|
+
except PortalCaptureError as exc:
|
|
396
|
+
raise CaptureStartError(f"portal capture setup failed: {exc}") from exc
|
|
397
|
+
|
|
398
|
+
if session.size is not None and not output_resolution:
|
|
399
|
+
out_w, out_h = session.size[0], session.size[1]
|
|
400
|
+
|
|
401
|
+
# Normalize to 16:9
|
|
402
|
+
if not output_resolution:
|
|
403
|
+
out_w, out_h = 1920, 1080
|
|
404
|
+
|
|
405
|
+
video_chain = [
|
|
406
|
+
"pipewiresrc",
|
|
407
|
+
f"fd={session.pw_fd}",
|
|
408
|
+
f"path={session.pw_node_id}",
|
|
409
|
+
"do-timestamp=true",
|
|
410
|
+
# always-copy=true!! immediately copy PipeWire buffer to CPU memory so
|
|
411
|
+
# x264enc doesn't stall waiting for DMA-buf download mid-pipeline
|
|
412
|
+
"always-copy=true",
|
|
413
|
+
"!", "queue", "max-size-buffers=8", "max-size-time=0", "leaky=downstream",
|
|
414
|
+
"!", "videorate", "skip-to-first=true",
|
|
415
|
+
"!", f"video/x-raw,framerate={fps}/1",
|
|
416
|
+
"!", "videoconvert",
|
|
417
|
+
"!", "videoscale", "add-borders=false",
|
|
418
|
+
"!", f"video/x-raw,width={out_w},height={out_h},format=I420,pixel-aspect-ratio=1/1",
|
|
419
|
+
"!", "x264enc",
|
|
420
|
+
"tune=zerolatency",
|
|
421
|
+
"speed-preset=ultrafast",
|
|
422
|
+
f"bitrate={bitrate_kbits}",
|
|
423
|
+
f"key-int-max={fps}",
|
|
424
|
+
"threads=0",
|
|
425
|
+
"bframes=0",
|
|
426
|
+
"byte-stream=true",
|
|
427
|
+
"aud=true",
|
|
428
|
+
"!", "h264parse", "config-interval=-1",
|
|
429
|
+
"!", "video/x-h264,stream-format=byte-stream,alignment=au,profile=baseline",
|
|
430
|
+
"!", "sink.video",
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
audio_chain: list[str] = []
|
|
434
|
+
if has_audio:
|
|
435
|
+
audio_chain = [
|
|
436
|
+
"pulsesrc", f"device={audio_monitor}", "do-timestamp=true",
|
|
437
|
+
"!", "audioconvert",
|
|
438
|
+
"!", "audioresample",
|
|
439
|
+
"!", "audio/x-raw,rate=48000,channels=2",
|
|
440
|
+
"!", audio_enc, "bitrate=128000",
|
|
441
|
+
"!", "aacparse",
|
|
442
|
+
"!", "sink.audio",
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
gst_cmd = [
|
|
446
|
+
"gst-launch-1.0", "-e", "-q",
|
|
447
|
+
"hlssink2", "name=sink",
|
|
448
|
+
f"location={hls_dir}/stream%05d.ts",
|
|
449
|
+
f"playlist-location={hls_dir}/stream.m3u8",
|
|
450
|
+
"target-duration=1",
|
|
451
|
+
"playlist-length=3",
|
|
452
|
+
"max-files=4",
|
|
453
|
+
*video_chain,
|
|
454
|
+
*audio_chain,
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
print(f"[FluxCast] Portal capture: node={session.pw_node_id}, output={out_w}x{out_h}@{fps}")
|
|
458
|
+
if session.position and session.size:
|
|
459
|
+
print(
|
|
460
|
+
f"[FluxCast] Portal source: "
|
|
461
|
+
f"pos={session.position[0]},{session.position[1]} "
|
|
462
|
+
f"size={session.size[0]}x{session.size[1]}"
|
|
463
|
+
)
|
|
464
|
+
if has_audio:
|
|
465
|
+
print(f"[FluxCast] Capturing audio: {audio_monitor}")
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
gst_proc = subprocess.Popen(
|
|
469
|
+
gst_cmd,
|
|
470
|
+
stdout=subprocess.DEVNULL,
|
|
471
|
+
stderr=subprocess.DEVNULL,
|
|
472
|
+
pass_fds=(session.pw_fd,),
|
|
473
|
+
)
|
|
474
|
+
except FileNotFoundError as exc:
|
|
475
|
+
close_portal_capture(session)
|
|
476
|
+
raise CaptureStartError(f"gst-launch-1.0 not found: {exc}") from exc
|
|
477
|
+
|
|
478
|
+
time.sleep(2.5)
|
|
479
|
+
if gst_proc.poll() is not None:
|
|
480
|
+
close_portal_capture(session)
|
|
481
|
+
raise CaptureStartError("portal GStreamer HLS pipeline exited immediately")
|
|
482
|
+
|
|
483
|
+
return CaptureResult(process=gst_proc, portal_session=session)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _start_capture_x11grab(
|
|
487
|
+
monitor: Monitor,
|
|
488
|
+
fps: int = 30,
|
|
489
|
+
bitrate: str = "4M",
|
|
490
|
+
output_resolution: Optional[str] = None,
|
|
491
|
+
) -> "subprocess.Popen[bytes]":
|
|
492
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
493
|
+
if not ffmpeg:
|
|
494
|
+
raise CaptureStartError("ffmpeg is required for x11grab backend")
|
|
495
|
+
if not os.environ.get("DISPLAY"):
|
|
496
|
+
raise CaptureStartError("DISPLAY is not set for x11grab")
|
|
497
|
+
|
|
498
|
+
display = os.environ.get("DISPLAY", monitor.display or ":0")
|
|
499
|
+
src_res = f"{monitor.width}x{monitor.height}"
|
|
500
|
+
out_res = output_resolution or src_res
|
|
501
|
+
audio_monitor = _default_audio_monitor()
|
|
502
|
+
hls_dir = "/tmp/fluxcast"
|
|
503
|
+
|
|
504
|
+
if os.path.exists(hls_dir):
|
|
505
|
+
shutil.rmtree(hls_dir)
|
|
506
|
+
os.makedirs(hls_dir, exist_ok=True)
|
|
507
|
+
|
|
508
|
+
cmd = [
|
|
509
|
+
ffmpeg,
|
|
510
|
+
"-hide_banner",
|
|
511
|
+
"-loglevel", "warning",
|
|
512
|
+
"-y",
|
|
513
|
+
"-thread_queue_size", "1024",
|
|
514
|
+
"-f", "x11grab",
|
|
515
|
+
"-framerate", str(fps),
|
|
516
|
+
"-video_size", src_res,
|
|
517
|
+
"-i", f"{display}+{monitor.x},{monitor.y}",
|
|
518
|
+
"-thread_queue_size", "1024",
|
|
519
|
+
"-f", "pulse",
|
|
520
|
+
"-i", audio_monitor,
|
|
521
|
+
"-map", "0:v:0",
|
|
522
|
+
"-map", "1:a:0",
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
if out_res != src_res:
|
|
526
|
+
cmd += ["-vf", f"scale={out_res.replace('x', ':')}:flags=bilinear,format=yuv420p"]
|
|
527
|
+
else:
|
|
528
|
+
cmd += ["-vf", "format=yuv420p"]
|
|
529
|
+
|
|
530
|
+
cmd += [
|
|
531
|
+
"-c:v", "libx264",
|
|
532
|
+
"-preset", "veryfast",
|
|
533
|
+
"-tune", "zerolatency",
|
|
534
|
+
"-pix_fmt", "yuv420p",
|
|
535
|
+
"-r", str(fps),
|
|
536
|
+
"-g", str(max(1, fps)),
|
|
537
|
+
"-b:v", bitrate,
|
|
538
|
+
"-maxrate", bitrate,
|
|
539
|
+
"-bufsize", "2M",
|
|
540
|
+
"-c:a", "aac",
|
|
541
|
+
"-b:a", "128k",
|
|
542
|
+
"-ac", "2",
|
|
543
|
+
"-ar", "48000",
|
|
544
|
+
"-f", "hls",
|
|
545
|
+
"-hls_time", "2",
|
|
546
|
+
"-hls_list_size", "6",
|
|
547
|
+
"-hls_flags", "append_list",
|
|
548
|
+
"-hls_segment_filename", os.path.join(hls_dir, "stream%d.ts"),
|
|
549
|
+
os.path.join(hls_dir, "stream.m3u8"),
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
print(f"[FluxCast] Capturing X11 region : {display}+{monitor.x},{monitor.y} ({src_res})")
|
|
553
|
+
if out_res != src_res:
|
|
554
|
+
print(f"[FluxCast] Scaling output to : {out_res}")
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
558
|
+
except FileNotFoundError as exc:
|
|
559
|
+
raise CaptureStartError(f"required executable not found: {exc}") from exc
|
|
560
|
+
|
|
561
|
+
time.sleep(1.5)
|
|
562
|
+
if process.poll() is not None:
|
|
563
|
+
raise CaptureStartError("x11grab capture exited immediately")
|
|
564
|
+
return process
|
cast.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import pychromecast
|
|
7
|
+
except ImportError:
|
|
8
|
+
print("[FluxCast] ERROR: pychromecast is not installed. "
|
|
9
|
+
"Run: pip install pychromecast")
|
|
10
|
+
sys.exit(1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_devices(timeout: int = 10) -> list:
|
|
14
|
+
print(f"[FluxCast] Searching for Cast devices (timeout={timeout}s)…")
|
|
15
|
+
chromecasts, browser = pychromecast.get_chromecasts(timeout=timeout)
|
|
16
|
+
pychromecast.discovery.stop_discovery(browser)
|
|
17
|
+
return chromecasts
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def connect_by_ip(ip: str, port: int = 8009):
|
|
21
|
+
print(f"[FluxCast] Connecting directly to Cast device at {ip}:{port}…")
|
|
22
|
+
try:
|
|
23
|
+
known = [f"{ip}:{port}"] if port != 8009 else [ip]
|
|
24
|
+
chromecasts, browser = pychromecast.get_listed_chromecasts(known_hosts=known)
|
|
25
|
+
pychromecast.discovery.stop_discovery(browser)
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
print(f"[FluxCast] ERROR: Discovery failed — {exc}")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
if not chromecasts:
|
|
31
|
+
print(f"[FluxCast] ERROR: No Cast device responded at {ip}:{port}.")
|
|
32
|
+
print("[FluxCast] Make sure the TV is ON, on the same network, "
|
|
33
|
+
"and Cast is enabled in Settings → General → External Device Manager.")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
cast = chromecasts[0]
|
|
37
|
+
cast.wait(timeout=10)
|
|
38
|
+
print(f"[FluxCast] Connected: {cast.cast_info.friendly_name}")
|
|
39
|
+
return cast
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prompt_device(devices: list, device_name: Optional[str] = None):
|
|
43
|
+
if not devices:
|
|
44
|
+
print("[FluxCast] ERROR: No Cast devices found on the network.")
|
|
45
|
+
print("[FluxCast] TIP: Use --tv-ip <IP> to connect directly, e.g.:")
|
|
46
|
+
print("[FluxCast] python main.py --protocol cast --tv-ip 192.168.100.XXX")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
if device_name is not None:
|
|
50
|
+
needle = device_name.lower()
|
|
51
|
+
for cc in devices:
|
|
52
|
+
if cc.cast_info.friendly_name.lower() == needle:
|
|
53
|
+
return cc
|
|
54
|
+
print(f"[FluxCast] WARNING: Device '{device_name}' not found; falling back to picker.")
|
|
55
|
+
|
|
56
|
+
print("\n[FluxCast] Found Cast device(s):")
|
|
57
|
+
for i, cc in enumerate(devices):
|
|
58
|
+
name = cc.cast_info.friendly_name
|
|
59
|
+
model = cc.cast_info.model_name
|
|
60
|
+
host = cc.cast_info.host
|
|
61
|
+
print(f" [{i}] {name} ({model}) — {host}")
|
|
62
|
+
|
|
63
|
+
default_idx = 0
|
|
64
|
+
for i, cc in enumerate(devices):
|
|
65
|
+
if "samsung" in cc.cast_info.model_name.lower():
|
|
66
|
+
default_idx = i
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
raw = input(f"Select device [{default_idx}]: ").strip()
|
|
70
|
+
try:
|
|
71
|
+
idx = int(raw) if raw else default_idx
|
|
72
|
+
return devices[idx]
|
|
73
|
+
except (ValueError, IndexError):
|
|
74
|
+
print(f"[FluxCast] Invalid choice, using device {default_idx}.")
|
|
75
|
+
return devices[default_idx]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def start_cast(device, stream_url: str) -> None:
|
|
79
|
+
device.wait()
|
|
80
|
+
mc = device.media_controller
|
|
81
|
+
mc.play_media(stream_url, "video/mp2t")
|
|
82
|
+
mc.block_until_active(timeout=15)
|
|
83
|
+
print(f"[FluxCast] Cast started → {device.cast_info.friendly_name}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stop_cast(device: Optional[object]) -> None:
|
|
87
|
+
if device is None:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
device.media_controller.stop()
|
|
91
|
+
device.disconnect(timeout=5)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|