Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
210 lines
9.8 KiB
Python
210 lines
9.8 KiB
Python
"""Fail-closed PeerTube 0.2.0 adapter; freeze with PyInstaller as a real PE .exe.
|
|
Video encoding is always NVENC. CPU image/audio processing is permitted.
|
|
Only reviewed argument shapes are accepted, including explicit copy-only remux.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
HERE = Path(sys.executable if getattr(sys, 'frozen', False) else __file__).resolve().parent
|
|
REAL = Path(os.environ.get('PT_FFMPEG_EXE', str(HERE / 'ffmpeg.exe')))
|
|
PROBE = Path(os.environ.get('PT_FFPROBE_EXE', str(HERE / 'ffprobe.exe')))
|
|
STATE = Path(os.environ.get('PT_STATE_DIR', r'C:\pt-worker\state'))
|
|
DEVICE = os.environ.get('PT_NVENC_DEVICE', '0')
|
|
TARGET = os.environ.get('PT_NVENC_TARGET', 'h264_nvenc')
|
|
QUERIES = ('-version', '-formats', '-encoders', '-filters', '-codecs', '-hwaccels')
|
|
FLAGS = {'-y', '-n', '-nostdin', '-hide_banner', '-an', '-vn', '-shortest', '-copyts'}
|
|
VALUES = {'-i', '-vcodec', '-c:v', '-codec:v', '-acodec', '-c:a', '-codec:a', '-c',
|
|
'-vf', '-filter:v', '-pix_fmt', '-preset', '-preset:v', '-b_strategy', '-bf',
|
|
'-tune', '-crf', '-maxrate', '-maxrate:v', '-bufsize', '-bufsize:v',
|
|
'-b:v', '-vb', '-b:a', '-ab', '-q:a', '-g', '-g:v', '-r', '-r:v',
|
|
'-threads', '-f', '-movflags', '-map', '-map_metadata', '-max_muxing_queue_size',
|
|
'-channel_layout', '-ac', '-ar', '-t', '-loop', '-hls_time', '-hls_list_size',
|
|
'-hls_playlist_type', '-hls_segment_filename', '-hls_segment_type', '-hls_flags',
|
|
'-bsf:a', '-loglevel', '-v', '-sc_threshold'}
|
|
|
|
|
|
def event(kind, **fields):
|
|
try:
|
|
STATE.mkdir(parents=True, exist_ok=True)
|
|
with (STATE / 'events.jsonl').open('a', encoding='utf8') as f:
|
|
f.write(json.dumps({'time': time.time(), 'kind': kind, **fields}) + '\n')
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def probe(filename):
|
|
p = subprocess.run([str(PROBE), '-v', 'error', '-show_streams', '-show_format',
|
|
'-of', 'json', filename], capture_output=True, timeout=30)
|
|
if p.returncode:
|
|
raise ValueError('probe failed')
|
|
return json.loads(p.stdout)
|
|
|
|
|
|
def parse(args):
|
|
if len(args) < 2 or args[-1].startswith('-'):
|
|
raise ValueError('missing output')
|
|
entries, i = [], 0
|
|
while i < len(args) - 1:
|
|
key = args[i]
|
|
if key in FLAGS:
|
|
entries.append((key, None)); i += 1
|
|
elif key in VALUES and i + 1 < len(args) - 1:
|
|
entries.append((key, args[i + 1])); i += 2
|
|
else:
|
|
raise ValueError('unknown option or extra output')
|
|
return entries
|
|
|
|
|
|
def rewrite(args, input_probe=None):
|
|
if len(args) == 1 and args[0] in QUERIES:
|
|
return args, None
|
|
if TARGET not in ('h264_nvenc', 'hevc_nvenc') or not DEVICE.isdigit() or (STATE / 'gpu-unavailable').exists():
|
|
raise ValueError('GPU disabled')
|
|
entries = parse(args)
|
|
# No codec aliases, extra output streams, or global software encoders can escape validation.
|
|
video = [(k, v) for k, v in entries if k in ('-vcodec', '-c:v', '-codec:v')]
|
|
generic = [v for k, v in entries if k == '-c']
|
|
audio = [v for k, v in entries if k in ('-acodec', '-c:a', '-codec:a')]
|
|
if any(v not in ('copy', 'aac', 'libfdk_aac', 'libmp3lame') for v in audio):
|
|
raise ValueError('unknown audio encoder')
|
|
if generic:
|
|
if generic != ['copy'] or video or audio:
|
|
raise ValueError('unreviewed global codec')
|
|
return args, None
|
|
if len(video) > 1:
|
|
raise ValueError('multiple video encoders')
|
|
if '-vn' in args:
|
|
if video:
|
|
raise ValueError('contradictory video options')
|
|
return args, None
|
|
if video and video[0][1] == 'copy':
|
|
return args, None
|
|
if len(video) != 1 or video[0][1] != 'libx264':
|
|
raise ValueError('unknown video encoder')
|
|
inputs = [v for k, v in entries if k == '-i']
|
|
if not inputs or len(inputs) > 2:
|
|
raise ValueError('unreviewed inputs')
|
|
data = input_probe if input_probe is not None else probe(inputs[0])
|
|
stream = next(s for s in data['streams'] if s['codec_type'] == 'video')
|
|
codec = stream['codec_name']
|
|
still = codec in ('png', 'mjpeg', 'webp') and '-loop' in args
|
|
if not still and codec not in ('h264', 'hevc', 'vp9'):
|
|
raise ValueError('source has no reviewed NVDEC path')
|
|
if not still and stream.get('pix_fmt') not in ('yuv420p', 'yuv420p10le', 'nv12', 'p010le'):
|
|
raise ValueError('unsupported pixel format')
|
|
if stream.get('color_transfer') in ('smpte2084', 'arib-std-b67'):
|
|
raise ValueError('HDR needs reviewed tone mapping')
|
|
if stream.get('tags', {}).get('rotate') or any(s.get('rotation', 0) for s in stream.get('side_data_list', [])):
|
|
raise ValueError('rotation needs reviewed GPU path')
|
|
width, height = stream['width'], stream['height']
|
|
w, h = width // 2 * 2, height // 2 * 2
|
|
filters = [v for k, v in entries if k in ('-vf', '-filter:v')]
|
|
if len(filters) > 1:
|
|
raise ValueError('multiple filters')
|
|
if filters:
|
|
value = filters[0]
|
|
if value == 'scale=trunc(iw/2)*2:trunc(ih/2)*2' and still:
|
|
pass
|
|
else:
|
|
match = re.fullmatch(r'scale=w=(-2|\d+):h=(-2|\d+)', value)
|
|
if not match:
|
|
raise ValueError('unreviewed filter')
|
|
w, h = map(int, match.groups())
|
|
if w == -2 and h > 0:
|
|
w = max(2, round(width * h / height / 2) * 2)
|
|
elif h == -2 and w > 0:
|
|
h = max(2, round(height * w / width / 2) * 2)
|
|
if w <= 0 or h <= 0 or w % 2 or h % 2 or w > 7680 or h > 4320:
|
|
raise ValueError('invalid dimensions')
|
|
out = ['-init_hw_device', f'cuda=nv:{DEVICE}', '-filter_hw_device', 'nv']
|
|
first = True
|
|
for key, value in entries:
|
|
if key == '-i' and first:
|
|
if not still:
|
|
out += ['-hwaccel', 'cuda', '-hwaccel_device', DEVICE, '-hwaccel_output_format', 'cuda', '-noautorotate']
|
|
first = False
|
|
if key in ('-pix_fmt', '-preset', '-preset:v', '-b_strategy', '-bf', '-tune', '-crf', '-vf', '-filter:v'):
|
|
continue
|
|
if key in ('-vcodec', '-c:v', '-codec:v'):
|
|
value = TARGET
|
|
out.append(key)
|
|
if value is not None:
|
|
out.append(value)
|
|
prefix = 'format=nv12,hwupload_cuda,' if still else ''
|
|
out += ['-vf', prefix + f'scale_cuda=w={w}:h={h}:format=nv12',
|
|
'-preset', 'p4', '-gpu', DEVICE, '-bf', '2', '-rc', 'vbr']
|
|
rate = next((v for k, v in entries if k in ('-maxrate:v', '-maxrate')), None)
|
|
if rate and not any(k in ('-b:v', '-vb') for k, _ in entries):
|
|
out += ['-b:v', rate]
|
|
out.append(args[-1])
|
|
uuid = None
|
|
try:
|
|
uuid = json.loads((STATE / 'inputs' / (Path(inputs[0]).name + '.json')).read_text())['uuid']
|
|
except (OSError, ValueError, KeyError):
|
|
pass
|
|
return out, {'job_uuid': uuid, 'source_codec': codec, 'target_codec': 'hevc' if TARGET == 'hevc_nvenc' else 'h264', 'encoder': TARGET,
|
|
'source_width': width, 'source_height': height, 'target_width': w, 'target_height': h,
|
|
'duration': float(data.get('format', {}).get('duration', 0) or 0), 'static_image_upload': still}
|
|
|
|
|
|
def encoder_util():
|
|
try:
|
|
p = subprocess.run(['nvidia-smi.exe', '-i', DEVICE, '--query-gpu=utilization.encoder',
|
|
'--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=3)
|
|
return float(p.stdout.strip()) if p.returncode == 0 else None
|
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
return None
|
|
|
|
|
|
def main():
|
|
try:
|
|
args, info = rewrite(sys.argv[1:])
|
|
if not REAL.is_file():
|
|
raise ValueError('ffmpeg missing')
|
|
if info is None:
|
|
sys.exit(subprocess.call([str(REAL), *args]))
|
|
start = time.monotonic()
|
|
# Do not persist ffmpeg command lines, media paths, stderr, or signed URLs.
|
|
p = subprocess.Popen([str(REAL), *args], stdout=sys.stdout, stderr=subprocess.PIPE,
|
|
text=True, encoding='utf8', errors='replace')
|
|
progress = {}
|
|
def consume():
|
|
for line in p.stderr:
|
|
sys.stderr.write(line); sys.stderr.flush()
|
|
for k, v in re.findall(r'(frame|fps|speed)=\s*([\d.]+)', line):
|
|
progress[k] = float(v)
|
|
reader = threading.Thread(target=consume, daemon=True); reader.start()
|
|
samples = []
|
|
while p.poll() is None:
|
|
value = encoder_util()
|
|
if value is not None:
|
|
samples.append(value)
|
|
time.sleep(.2)
|
|
reader.join(timeout=2)
|
|
seconds = time.monotonic() - start
|
|
maximum = max(samples) if samples else None
|
|
# Short jobs can end inside the utilization averaging interval. They still
|
|
# explicitly use NVENC; boot/driver self-test provides mandatory GPU proof.
|
|
no_signal = seconds >= 3 and (maximum is None or maximum == 0)
|
|
rc = p.returncode or (78 if no_signal else 0)
|
|
event('encode', **info, seconds=seconds, returncode=rc, ffmpeg_returncode=p.returncode,
|
|
engine_busy_max=maximum, engine_samples=len(samples), engine_counters_readable=bool(samples),
|
|
frames=progress.get('frame'), ffmpeg_fps=progress.get('fps'), ffmpeg_speed=progress.get('speed'),
|
|
realtime_factor=info['duration'] / seconds if info['duration'] else None)
|
|
if rc:
|
|
event('gpu_failure', reason='nvenc_transcode_failed' if p.returncode else 'no_encoder_signal')
|
|
sys.exit(rc)
|
|
except (ValueError, OSError, subprocess.SubprocessError, KeyError, StopIteration):
|
|
event('gpu_failure', reason='unreviewed_or_unavailable_nvenc')
|
|
print('PT-WORKER: NVENC adapter refused command; drain required', file=sys.stderr)
|
|
sys.exit(78)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|