Spaces:
Running
Running
v2.12-matchgop: copy-first; on YouTube noData/gopSizeLong switch THIS publisher to same-size closed GOP 2s (not default 720). Cuts live by operator request.
Browse files- ingest.py +1 -1
- relay.py +37 -12
- youtube_manager.py +18 -4
ingest.py
CHANGED
|
@@ -6,7 +6,7 @@ import os, threading, time
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
-
APP_VERSION = 'relay-v2.
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
+
APP_VERSION = 'relay-v2.12-matchgop'
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
relay.py
CHANGED
|
@@ -162,8 +162,8 @@ class Sender:
|
|
| 162 |
fixed720 -> constant 720x1280 canvas, re-encodes (lighter on CPU)
|
| 163 |
"""
|
| 164 |
mode=(self.mode_override or os.getenv('YOUTUBE_SEND_MODE','auto')).strip().lower()
|
| 165 |
-
if mode not in ('auto','copy','fixed1080','fixed720'):
|
| 166 |
-
raise ValueError('وضع الإرسال يجب أن يكون auto أو copy أو fixed1080 أو fixed720')
|
| 167 |
# Sticky transcode is for HEVC / layout flap only. Measured 2026-09-27 on cpu-basic:
|
| 168 |
# two fixed720 publishers used 1.67–1.85 of 2.0 quota (peak 92.6%) with YouTube
|
| 169 |
# health still good — no headroom for a third. If this candidate is H264, copy.
|
|
@@ -215,6 +215,24 @@ class Sender:
|
|
| 215 |
cand=self.current_candidate or {}
|
| 216 |
blob=(str(cand.get('codec') or '')+' '+str(cand.get('label') or '')+' '+str(cand.get('brief') or '')).lower()
|
| 217 |
return any(k in blob for k in ('h264','avc'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
def note_format_change(self):
|
| 219 |
self.flap.append(time.monotonic())
|
| 220 |
if len(self.flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
|
|
@@ -254,8 +272,8 @@ class Sender:
|
|
| 254 |
label=str(cand.get('label') or cand.get('brief') or '')
|
| 255 |
if any(k in str(declared).lower() for k in ('h264','avc')) or any(k in label.lower() for k in ('h264','avc')):
|
| 256 |
return None
|
| 257 |
-
mode=os.getenv('YOUTUBE_CODEC_FALLBACK_MODE','
|
| 258 |
-
if mode not in ('fixed1080','fixed720'):mode='
|
| 259 |
self.sticky_mode=mode
|
| 260 |
self.sticky_since=time.monotonic()
|
| 261 |
self._recovering_copy=False
|
|
@@ -354,14 +372,21 @@ class Sender:
|
|
| 354 |
if mode=='copy':
|
| 355 |
video=['-c:v','copy']
|
| 356 |
else:
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
'-tune','zerolatency','-profile:v','high','-crf',os.getenv('YOUTUBE_CRF','22'),
|
| 366 |
# -b:v is what makes VIDEO_BITRATE real: -crf alone lets x264 roam and
|
| 367 |
# -maxrate only clamps the peaks, so a "4000k" promise to YouTube was not kept.
|
|
|
|
| 162 |
fixed720 -> constant 720x1280 canvas, re-encodes (lighter on CPU)
|
| 163 |
"""
|
| 164 |
mode=(self.mode_override or os.getenv('YOUTUBE_SEND_MODE','auto')).strip().lower()
|
| 165 |
+
if mode not in ('auto','copy','fixed1080','fixed720','matchgop'):
|
| 166 |
+
raise ValueError('وضع الإرسال يجب أن يكون auto أو copy أو fixed1080 أو fixed720 أو matchgop')
|
| 167 |
# Sticky transcode is for HEVC / layout flap only. Measured 2026-09-27 on cpu-basic:
|
| 168 |
# two fixed720 publishers used 1.67–1.85 of 2.0 quota (peak 92.6%) with YouTube
|
| 169 |
# health still good — no headroom for a third. If this candidate is H264, copy.
|
|
|
|
| 215 |
cand=self.current_candidate or {}
|
| 216 |
blob=(str(cand.get('codec') or '')+' '+str(cand.get('label') or '')+' '+str(cand.get('brief') or '')).lower()
|
| 217 |
return any(k in blob for k in ('h264','avc'))
|
| 218 |
+
def request_sticky(self, mode, user, reason):
|
| 219 |
+
"""Per-broadcaster: stick LIGHT/matchgop and end this FFmpeg only — not the Space."""
|
| 220 |
+
mode=(mode or 'matchgop').strip().lower()
|
| 221 |
+
if mode not in ('matchgop','fixed720','fixed1080'): mode='matchgop'
|
| 222 |
+
if self.sticky_mode==mode and self.session:
|
| 223 |
+
return False
|
| 224 |
+
self.sticky_mode=mode
|
| 225 |
+
self.sticky_since=time.monotonic()
|
| 226 |
+
self._recovering_copy=False
|
| 227 |
+
self.log(user, '[HEALTH] %s — sticky %s (this publisher only)'%(reason[:160], mode))
|
| 228 |
+
sess=self.session
|
| 229 |
+
if isinstance(sess, dict):
|
| 230 |
+
sess['force_stop']=True
|
| 231 |
+
p=self.process
|
| 232 |
+
if p is not None and p.stdin:
|
| 233 |
+
try: p.stdin.close()
|
| 234 |
+
except (OSError, ValueError): pass
|
| 235 |
+
return True
|
| 236 |
def note_format_change(self):
|
| 237 |
self.flap.append(time.monotonic())
|
| 238 |
if len(self.flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
|
|
|
|
| 272 |
label=str(cand.get('label') or cand.get('brief') or '')
|
| 273 |
if any(k in str(declared).lower() for k in ('h264','avc')) or any(k in label.lower() for k in ('h264','avc')):
|
| 274 |
return None
|
| 275 |
+
mode=os.getenv('YOUTUBE_CODEC_FALLBACK_MODE','matchgop').strip().lower()
|
| 276 |
+
if mode not in ('fixed1080','fixed720','matchgop'):mode='matchgop'
|
| 277 |
self.sticky_mode=mode
|
| 278 |
self.sticky_since=time.monotonic()
|
| 279 |
self._recovering_copy=False
|
|
|
|
| 372 |
if mode=='copy':
|
| 373 |
video=['-c:v','copy']
|
| 374 |
else:
|
| 375 |
+
if mode=='matchgop':
|
| 376 |
+
# Same-size closed GOP ~2s: YouTube buffers on open/8s copy (gopSizeLong / noData).
|
| 377 |
+
# Do NOT squash to 720 unless flap/HEVC policy asked for a constant canvas.
|
| 378 |
+
w=int((candidate or {}).get('width') or 0); h=int((candidate or {}).get('height') or 0)
|
| 379 |
+
if w>=16 and h>=16:
|
| 380 |
+
vf='scale=%d:%d:flags=lanczos,setsar=1,format=yuv420p'%(w-w%2, h-h%2)
|
| 381 |
+
else:
|
| 382 |
+
vf='scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,format=yuv420p'
|
| 383 |
+
bitrate=os.getenv('VIDEO_BITRATE','').strip() or '4500k'
|
| 384 |
+
else:
|
| 385 |
+
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
| 386 |
+
vf=self.canvas(width,height)
|
| 387 |
+
bitrate=os.getenv('VIDEO_BITRATE','').strip() or bitrate
|
| 388 |
+
bufsize=str(int(int(str(bitrate).rstrip('kK'))*1.5))+'k'
|
| 389 |
+
video=['-vf',vf,'-c:v','libx264','-preset',os.getenv('YOUTUBE_X264_PRESET','veryfast'),
|
| 390 |
'-tune','zerolatency','-profile:v','high','-crf',os.getenv('YOUTUBE_CRF','22'),
|
| 391 |
# -b:v is what makes VIDEO_BITRATE real: -crf alone lets x264 roam and
|
| 392 |
# -maxrate only clamps the peaks, so a "4000k" promise to YouTube was not kept.
|
youtube_manager.py
CHANGED
|
@@ -325,8 +325,8 @@ class ManagedYouTube:
|
|
| 325 |
with self.operations:
|
| 326 |
d=self.journal.read()
|
| 327 |
clean={k:str(values.get(k,'')).strip() for k in ('name','privacy','title','description','send_mode')}
|
| 328 |
-
if clean['send_mode'] and clean['send_mode'] not in ('auto','copy','fixed1080','fixed720'):
|
| 329 |
-
raise ValueError('وضع الإرسال يجب أن يكون auto أو copy أو fixed1080 أو fixed720')
|
| 330 |
if clean['privacy'] not in ('public','unlisted','private'):raise ValueError('خصوصية غير صالحة')
|
| 331 |
if 'send_mode' in values:
|
| 332 |
# The ingestion mode is safe to change mid-broadcast: it only decides what the
|
|
@@ -1646,7 +1646,7 @@ class ManagedYouTube:
|
|
| 1646 |
(videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
|
| 1647 |
openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
|
| 1648 |
cadence than the life-cycle check so it stays inside the quota budget."""
|
| 1649 |
-
interval=max(
|
| 1650 |
if time.time()-float(ev.get('last_health') or 0)<interval:return
|
| 1651 |
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 1652 |
if not stream_id:return
|
|
@@ -1661,9 +1661,23 @@ class ManagedYouTube:
|
|
| 1661 |
issues.append('%s [%s] %s'%(i.get('type',''),sev,i.get('reason','')))
|
| 1662 |
text=' | '.join(issues)
|
| 1663 |
prev=ev.get('last_issues')
|
| 1664 |
-
|
|
|
|
| 1665 |
if text and text!=prev:
|
| 1666 |
self.log(user,'يوتيوب يشخّص البث: '+text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1667 |
def run_manager(self):
|
| 1668 |
checked=False
|
| 1669 |
last_tick=0.0
|
|
|
|
| 325 |
with self.operations:
|
| 326 |
d=self.journal.read()
|
| 327 |
clean={k:str(values.get(k,'')).strip() for k in ('name','privacy','title','description','send_mode')}
|
| 328 |
+
if clean['send_mode'] and clean['send_mode'] not in ('auto','copy','fixed1080','fixed720','matchgop'):
|
| 329 |
+
raise ValueError('وضع الإرسال يجب أن يكون auto أو copy أو fixed1080 أو fixed720 أو matchgop')
|
| 330 |
if clean['privacy'] not in ('public','unlisted','private'):raise ValueError('خصوصية غير صالحة')
|
| 331 |
if 'send_mode' in values:
|
| 332 |
# The ingestion mode is safe to change mid-broadcast: it only decides what the
|
|
|
|
| 1646 |
(videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
|
| 1647 |
openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
|
| 1648 |
cadence than the life-cycle check so it stays inside the quota budget."""
|
| 1649 |
+
interval=max(45,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','60')))
|
| 1650 |
if time.time()-float(ev.get('last_health') or 0)<interval:return
|
| 1651 |
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 1652 |
if not stream_id:return
|
|
|
|
| 1661 |
issues.append('%s [%s] %s'%(i.get('type',''),sev,i.get('reason','')))
|
| 1662 |
text=' | '.join(issues)
|
| 1663 |
prev=ev.get('last_issues')
|
| 1664 |
+
status=str(health.get('status') or '')
|
| 1665 |
+
self.event_update(key,youtube_health=status,youtube_issues=text,last_issues=text)
|
| 1666 |
if text and text!=prev:
|
| 1667 |
self.log(user,'يوتيوب يشخّص البث: '+text)
|
| 1668 |
+
# Copy that YouTube cannot play (noData / open GOP / long GOP) → same-size closed GOP.
|
| 1669 |
+
# Per-sender only; does not restart the Space or other publishers.
|
| 1670 |
+
blob=(text+' '+status).lower()
|
| 1671 |
+
gop_bad=any(k in blob for k in ('gopsizelong','gopsizeover','opengop'))
|
| 1672 |
+
nodata=status.lower()=='nodata'
|
| 1673 |
+
entry=self.senders.get(key)
|
| 1674 |
+
sender=entry['sender'] if entry and entry.get('thread') and entry['thread'].is_alive() else None
|
| 1675 |
+
if sender and (gop_bad or nodata):
|
| 1676 |
+
snap=sender.snapshot() or {}
|
| 1677 |
+
out=float(snap.get('output_seconds') or 0)
|
| 1678 |
+
mode=(snap.get('send_mode') or sender.send_mode() or '')
|
| 1679 |
+
if out>=20 and mode in ('','auto','copy') and not sender.sticky_mode:
|
| 1680 |
+
sender.request_sticky('matchgop', user, 'YouTube %s (out=%.0fs copy not ingesting for viewers)'%(status or text[:40], out))
|
| 1681 |
def run_manager(self):
|
| 1682 |
checked=False
|
| 1683 |
last_tick=0.0
|