Reoo88 commited on
Commit
cd90dad
·
verified ·
1 Parent(s): 0ed9a41

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
Files changed (3) hide show
  1. ingest.py +1 -1
  2. relay.py +37 -12
  3. 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.11-handoff'
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','fixed720').strip().lower()
258
- if mode not in ('fixed1080','fixed720'):mode='fixed720'
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
- width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
358
- # VIDEO_BITRATE was documented in the README but never read anywhere; it now caps the
359
- # transcode path (copy mode never re-encodes, so it has no bitrate to set).
360
- bitrate=os.getenv('VIDEO_BITRATE','').strip() or bitrate
361
- bufsize=str(int(int(bitrate[:-1])*1.5))+'k'
362
- # A constant canvas is the only thing that guarantees YouTube always receives
363
- # frames whose real size matches the signalled one, whatever the phone does.
364
- video=['-vf',self.canvas(width,height),'-c:v','libx264','-preset',os.getenv('YOUTUBE_X264_PRESET','veryfast'),
 
 
 
 
 
 
 
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(60,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','180')))
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
- self.event_update(key,youtube_health=health.get('status',''),youtube_issues=text,last_issues=text)
 
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