epoch
This commit is contained in:
commit
a16d211be7
|
@ -0,0 +1,2 @@
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
|
@ -0,0 +1,12 @@
|
||||||
|
# hemisphere
|
||||||
|
|
||||||
|
Multi-DJ live streaming toolkit.
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
* `liquidsoap` (git master)
|
||||||
|
* Python 3.6
|
||||||
|
- `trio_cdp`
|
||||||
|
- `pyzmq`
|
||||||
|
- `twitchio`
|
||||||
|
* `ffmpeg` (with ZMQ support)
|
|
@ -0,0 +1,2 @@
|
||||||
|
djs = ["shiz", "cyan"]
|
||||||
|
visualists = ["flopine", "merri"]
|
|
@ -0,0 +1,20 @@
|
||||||
|
%include "config.liq"
|
||||||
|
|
||||||
|
|
||||||
|
def create_sources(names, prefix, port)
|
||||||
|
list.mapi(fun (i, name) ->
|
||||||
|
input.srt(id=prefix ^ "-" ^ name, port=port + i, clock_safe=false),
|
||||||
|
names)
|
||||||
|
end
|
||||||
|
|
||||||
|
dj = (mix(id="dj", create_sources(djs, "dj", 9000)):source(2,1,0))
|
||||||
|
viz = (mix(id="viz", create_sources(visualists, "viz", 9100)):source(2,1,0))
|
||||||
|
|
||||||
|
master_audio = drop_video(dj)
|
||||||
|
master_video = drop_audio(fallback([dj, viz]))
|
||||||
|
master = mksafe(mux_video(master_audio, video=master_video))
|
||||||
|
|
||||||
|
|
||||||
|
%include "rtmp.liq"
|
||||||
|
|
||||||
|
output.rtmp.multi(master, id="out-rtmp", urls=["rtmp://localhost/app/live"])
|
|
@ -0,0 +1,52 @@
|
||||||
|
def output.rtmp(~id="",
|
||||||
|
~video_bitrate=2000,
|
||||||
|
~video_opts="byte-stream=false key-int-max=60 bframes=0 aud=true tune=zerolatency",
|
||||||
|
~video_encoder="x264enc",
|
||||||
|
~video_type="video/x-h264,profile=main",
|
||||||
|
~audio_bitrate=192000,
|
||||||
|
~audio_opts="",
|
||||||
|
~audio_encoder="faac",
|
||||||
|
~audio_type="audio/mpeg,mpegversion=4",
|
||||||
|
~url,
|
||||||
|
source) =
|
||||||
|
output.gstreamer.audio_video(source,
|
||||||
|
video_pipeline="videoconvert ! \
|
||||||
|
#{video_encoder} bitrate=#{video_bitrate} #{video_opts} ! \
|
||||||
|
#{video_type} ! queue ! mux.",
|
||||||
|
audio_pipeline = "audioconvert ! \
|
||||||
|
#{audio_encoder} bitrate=#{audio_bitrate} #{audio_opts} ! \
|
||||||
|
#{audio_type} ! queue ! mux.",
|
||||||
|
pipeline="flvmux streamable=true name=mux ! rtmpsink location=\"#{url}\""
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list.join(sep, l)
|
||||||
|
s = list.fold(fun (x, e) -> x ^ e ^ sep, "", l)
|
||||||
|
string.sub(s, start=0, length=string.length(s) - string.length(sep))
|
||||||
|
end
|
||||||
|
|
||||||
|
def output.rtmp.multi(~id="",
|
||||||
|
~video_bitrate=2000,
|
||||||
|
~video_opts="byte-stream=false key-int-max=60 bframes=0 aud=true tune=zerolatency",
|
||||||
|
~video_encoder="x264enc",
|
||||||
|
~video_type="video/x-h264,profile=main",
|
||||||
|
~audio_bitrate=192000,
|
||||||
|
~audio_opts="",
|
||||||
|
~audio_encoder="faac",
|
||||||
|
~audio_type="audio/mpeg,mpegversion=4",
|
||||||
|
~urls,
|
||||||
|
source) =
|
||||||
|
rtmp_pipelines = list.map(fun (url) ->
|
||||||
|
"rtmp. ! queue ! rtmpsink location=\"#{url}\"",
|
||||||
|
urls
|
||||||
|
)
|
||||||
|
output.gstreamer.audio_video(source,
|
||||||
|
video_pipeline="videoconvert ! \
|
||||||
|
#{video_encoder} bitrate=#{video_bitrate} #{video_opts} ! \
|
||||||
|
#{video_type} ! queue ! mux.",
|
||||||
|
audio_pipeline = "audioconvert ! \
|
||||||
|
#{audio_encoder} bitrate=#{audio_bitrate} #{audio_opts} ! \
|
||||||
|
#{audio_type} ! queue ! mux.",
|
||||||
|
pipeline="flvmux streamable=true name=mux ! tee name=rtmp " ^ list.join(" ", rtmp_pipelines)
|
||||||
|
)
|
||||||
|
end
|
|
@ -0,0 +1,44 @@
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import shlex
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from .obs import OBSScene
|
||||||
|
from .twitch_chat import make_twitch_chat
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('infile', type=argparse.FileType('r'), help='scene description file')
|
||||||
|
parser.add_argument('--nick-font', default='/Users/partynorge/down/Montserrat/Montserrat-Bold.ttf')
|
||||||
|
parser.add_argument('--chat-font', default='/Users/partynorge/down/Montserrat/Montserrat-Light.ttf')
|
||||||
|
parser.add_argument('-n', '--nickname', help='Twitch chat nickname', default='shizacular')
|
||||||
|
parser.add_argument('-t', '--token', help='Twitch OAuth token', required=True)
|
||||||
|
parser.add_argument('channel', help='Twitch channel')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
# Load scene
|
||||||
|
scene = OBSScene()
|
||||||
|
scene.load(args.infile)
|
||||||
|
|
||||||
|
# Convert scene graph
|
||||||
|
runners, graph = scene.to_ffmpeg()
|
||||||
|
chat_runner, chat_chains = make_twitch_chat('test', args.nickname, args.token, args.channel,
|
||||||
|
nick_args={'fontfile': args.nick_font},
|
||||||
|
chat_args={'fontfile': args.chat_font}
|
||||||
|
)
|
||||||
|
runners.extend(chat_runner)
|
||||||
|
graph.extend(chat_chains, link=True)
|
||||||
|
graph.fixup()
|
||||||
|
|
||||||
|
# Launch background runners
|
||||||
|
threads = {}
|
||||||
|
for r in runners:
|
||||||
|
t = threading.Thread(target=r, daemon=True)
|
||||||
|
t.start()
|
||||||
|
threads[r] = t
|
||||||
|
|
||||||
|
# Launch main process
|
||||||
|
chain = str(graph)
|
||||||
|
subprocess.run('ffmpeg -re -f lavfi -i ' + shlex.quote(chain) + ' -f nut -c:v libx264 -preset veryfast pipe:1 | ffplay -', shell=True)
|
|
@ -0,0 +1,194 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import trio
|
||||||
|
from trio_cdp import open_cdp, target
|
||||||
|
|
||||||
|
if sys.platform in ('win32', 'cygwin'):
|
||||||
|
CHROMIUM_BINARIES = [
|
||||||
|
r'{}\Google\Chrome\Application\chrome.exe'.format(os.environ['LocalAppData']),
|
||||||
|
]
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
CHROMIUM_BINARIES = [
|
||||||
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
CHROMIUM_BINARIES = ['chromium-browser', 'chromium', 'google-chrome', 'chrome']
|
||||||
|
|
||||||
|
CHROMIUM_ARGS = {
|
||||||
|
'hide-scrollbars',
|
||||||
|
'no-first-run',
|
||||||
|
'use-mock-keychain',
|
||||||
|
'password-store=basic',
|
||||||
|
'incognito',
|
||||||
|
}
|
||||||
|
CHROMIUM_ENABLES = {
|
||||||
|
'automation',
|
||||||
|
'features=NetworkService,NetworkServiceInProcess',
|
||||||
|
}
|
||||||
|
CHROMIUM_DISABLES = {
|
||||||
|
'background-networking',
|
||||||
|
'background-timer-throttling',
|
||||||
|
'backgrounding-occluded-windows',
|
||||||
|
'breakpad',
|
||||||
|
'client-side-phishing-detection',
|
||||||
|
'crash-reporter',
|
||||||
|
'component-extensions-with-background-pages',
|
||||||
|
'default-apps',
|
||||||
|
'dev-shm-usage',
|
||||||
|
'extensions',
|
||||||
|
'gaia-services',
|
||||||
|
'hang-monitor',
|
||||||
|
'ipc-flooding-protection',
|
||||||
|
'login-animations',
|
||||||
|
'login-screen-apps',
|
||||||
|
'notifications',
|
||||||
|
'popup-blocking',
|
||||||
|
'prompt-on-repost',
|
||||||
|
'renderer-backgrounding',
|
||||||
|
'search-geolocation-disclosure',
|
||||||
|
'sync',
|
||||||
|
'features=TranslateUI',
|
||||||
|
}
|
||||||
|
CHROMIUM_HEADLESS_ARGS = {
|
||||||
|
'headless'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# From: https://stackoverflow.com/questions/53575979/how-can-i-read-one-line-at-a-time-from-a-trio-receivestream
|
||||||
|
class LineReader:
|
||||||
|
def __init__(self, stream, max_line_length=16384):
|
||||||
|
self.stream = stream
|
||||||
|
self._line_generator = self.generate_lines(max_line_length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_lines(max_line_length):
|
||||||
|
buf = bytearray()
|
||||||
|
find_start = 0
|
||||||
|
while True:
|
||||||
|
newline_idx = buf.find(b'\n', find_start)
|
||||||
|
if newline_idx < 0:
|
||||||
|
# no b'\n' found in buf
|
||||||
|
if len(buf) > max_line_length:
|
||||||
|
raise ValueError("line too long")
|
||||||
|
# next time, start the search where this one left off
|
||||||
|
find_start = len(buf)
|
||||||
|
more_data = yield
|
||||||
|
else:
|
||||||
|
# b'\n' found in buf so return the line and move up buf
|
||||||
|
line = buf[:newline_idx+1]
|
||||||
|
# Update the buffer in place, to take advantage of bytearray's
|
||||||
|
# optimized delete-from-beginning feature.
|
||||||
|
del buf[:newline_idx+1]
|
||||||
|
# next time, start the search from the beginning
|
||||||
|
find_start = 0
|
||||||
|
more_data = yield line
|
||||||
|
|
||||||
|
if more_data is not None:
|
||||||
|
buf += bytes(more_data)
|
||||||
|
|
||||||
|
async def readline(self):
|
||||||
|
line = next(self._line_generator)
|
||||||
|
while line is None:
|
||||||
|
more_data = await self.stream.receive_some()
|
||||||
|
if not more_data:
|
||||||
|
return None
|
||||||
|
line = self._line_generator.send(more_data)
|
||||||
|
return line
|
||||||
|
|
||||||
|
async def readlines(self):
|
||||||
|
while True:
|
||||||
|
line = await self.readline()
|
||||||
|
if line is None:
|
||||||
|
break
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
class Chromium:
|
||||||
|
DEVTOOLS_PATTERN = re.compile('^DevTools listening on (\S+)$')
|
||||||
|
|
||||||
|
def __init__(self, binary=None, headless=True, args=[]):
|
||||||
|
self.binary = binary
|
||||||
|
self.headless = headless
|
||||||
|
self.args = args
|
||||||
|
self.profile_dir = None
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
async def launch(self):
|
||||||
|
if self.process:
|
||||||
|
await self.close()
|
||||||
|
self.profile_dir = tempfile.TemporaryDirectory()
|
||||||
|
|
||||||
|
base_args = (
|
||||||
|
['--disable-{}'.format(x) for x in CHROMIUM_DISABLES] +
|
||||||
|
['--enable-{}'.format(x) for x in CHROMIUM_ENABLES] +
|
||||||
|
['--{}'.format(x) for x in CHROMIUM_ARGS] +
|
||||||
|
[
|
||||||
|
'--user-data-dir={}'.format(self.profile_dir.name),
|
||||||
|
'--remote-debugging-port=0'
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if self.headless:
|
||||||
|
base_args += ['--{}'.format(x) for x in CHROMIUM_HEADLESS_ARGS]
|
||||||
|
if all(x.startswith('-') for x in self.args):
|
||||||
|
self.args.append('about:blank')
|
||||||
|
|
||||||
|
if not self.binary:
|
||||||
|
for path in CHROMIUM_BINARIES:
|
||||||
|
if '/' in path or '\\' in path:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
binary = path
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if shutil.which(path):
|
||||||
|
binary = path
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError('could not find Chromium executable!')
|
||||||
|
else:
|
||||||
|
binary = self.binary
|
||||||
|
|
||||||
|
self.process = await trio.open_process([binary] + base_args + self.args, stderr=subprocess.PIPE)
|
||||||
|
return self.process
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if not self.process:
|
||||||
|
return
|
||||||
|
self.process.terminate()
|
||||||
|
self.process = None
|
||||||
|
self.profile_dir.__exit__()
|
||||||
|
self.profile_dir = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
process = await self.launch()
|
||||||
|
reader = LineReader(process.stderr)
|
||||||
|
async for line in reader.readlines():
|
||||||
|
match = self.DEVTOOLS_PATTERN.match(line.decode('utf-8'))
|
||||||
|
if match:
|
||||||
|
cdp_url = match.group(1)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError('could not connect to Chromium')
|
||||||
|
|
||||||
|
return open_cdp(cdp_url)
|
||||||
|
|
||||||
|
async def select(self, conn):
|
||||||
|
targets = await target.get_targets()
|
||||||
|
for t in targets:
|
||||||
|
if t.attached:
|
||||||
|
continue
|
||||||
|
if t.type_ != 'page':
|
||||||
|
continue
|
||||||
|
if t.url.startswith('devtools://'):
|
||||||
|
continue
|
||||||
|
target_id = t.target_id
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError('could not find target')
|
||||||
|
|
||||||
|
# Create a new session with the chosen target.
|
||||||
|
return conn.open_session(target_id)
|
|
@ -0,0 +1,79 @@
|
||||||
|
def filter_escape(s):
|
||||||
|
return (str(s)
|
||||||
|
.replace(':', '\\:')
|
||||||
|
.replace(',', '\\,')
|
||||||
|
.replace(';', '\\;')
|
||||||
|
)
|
||||||
|
|
||||||
|
class FFmpegFilter:
|
||||||
|
def __init__(self, name, *, ins=[], outs=[], **args):
|
||||||
|
self.name = name
|
||||||
|
self.args = args
|
||||||
|
self.ins = ins
|
||||||
|
self.outs = outs
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '{}({}, {}, ins={!r}, outs={!r})'.format(
|
||||||
|
self.__class__.__name__,
|
||||||
|
self.name,
|
||||||
|
', '.join('{}={}'.format(k, v) for k, v in self.args.items()),
|
||||||
|
self.ins,
|
||||||
|
self.outs
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '{}{}{}{}{}'.format(
|
||||||
|
''.join('[{}] '.format(i) for i in self.ins),
|
||||||
|
filter_escape(self.name),
|
||||||
|
'=' if self.args else '',
|
||||||
|
':'.join('{}={}'.format(k, filter_escape(v)) for k, v in self.args.items()),
|
||||||
|
''.join(' [{}]'.format(o) for o in self.outs),
|
||||||
|
)
|
||||||
|
|
||||||
|
class FFmpegChain:
|
||||||
|
def __init__(self, *filters):
|
||||||
|
self.filters = list(filters)
|
||||||
|
|
||||||
|
def append(self, f):
|
||||||
|
self.filters.append(f)
|
||||||
|
|
||||||
|
def extend(self, l):
|
||||||
|
self.filters.extend(l)
|
||||||
|
|
||||||
|
def __getitem__(self, i):
|
||||||
|
return self.filters[i]
|
||||||
|
|
||||||
|
def __add__(self, o):
|
||||||
|
if not isinstance(o, FFmpegChain):
|
||||||
|
raise TypeError('incompatible')
|
||||||
|
return FFmpegChain(*(self.filters + o.filters))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '{}({})'.format(self.__class__.__name__, ', '.join(repr(f) for f in self.filters))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return ', '.join(str(f) for f in self.filters)
|
||||||
|
|
||||||
|
class FFmpegGraph:
|
||||||
|
def __init__(self, *chains):
|
||||||
|
self.chains = list(chains)
|
||||||
|
|
||||||
|
def append(self, f):
|
||||||
|
self.chains.append(f)
|
||||||
|
|
||||||
|
def extend(self, l, link=False):
|
||||||
|
if self.chains and link:
|
||||||
|
l[0][0].ins = self.chains[-1][-1].outs
|
||||||
|
self.chains.extend(l)
|
||||||
|
|
||||||
|
def fixup(self):
|
||||||
|
self.chains[-1][-1].outs = []
|
||||||
|
|
||||||
|
def __getitem__(self, i):
|
||||||
|
return self.chains[i]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '{}({})'.format(self.__class__.__name__, ', '.join(repr(c) for c in self.chains))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '; '.join(str(c) for c in self.chains)
|
|
@ -0,0 +1,2 @@
|
||||||
|
from .base import OBSSource, OBSScene
|
||||||
|
from . import browser, scene, text, video
|
|
@ -0,0 +1,60 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..filters import FFmpegGraph
|
||||||
|
|
||||||
|
|
||||||
|
class OBSSource:
|
||||||
|
TYPES = {}
|
||||||
|
|
||||||
|
def __init__(self, name='Untitled'):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def load(self, data):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, type, c):
|
||||||
|
cls.TYPES[type] = c
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def type(cls, type):
|
||||||
|
def inner(c):
|
||||||
|
cls.register(type, c)
|
||||||
|
return c
|
||||||
|
return inner
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_and_load(cls, data):
|
||||||
|
name = data['name']
|
||||||
|
type = data['id']
|
||||||
|
if type not in cls.TYPES:
|
||||||
|
raise NotImplementedType('unknown type: {}'.format(type))
|
||||||
|
|
||||||
|
s = cls.TYPES[type](name)
|
||||||
|
s.load(data)
|
||||||
|
return s
|
||||||
|
|
||||||
|
class OBSScene:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.sources = {}
|
||||||
|
self.current_scene = None
|
||||||
|
self.dimensions = (1280, 720)
|
||||||
|
|
||||||
|
def load(self, infile):
|
||||||
|
data = json.load(infile)
|
||||||
|
|
||||||
|
self.current_scene = data['current_scene']
|
||||||
|
for src in data['sources']:
|
||||||
|
self.sources[src['name']] = OBSSource.find_and_load(src)
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene=None):
|
||||||
|
scene = scene or self.current_scene
|
||||||
|
runners, chains = self.sources[scene].to_ffmpeg(self)
|
||||||
|
|
||||||
|
r = FFmpegGraph()
|
||||||
|
r.extend(chains)
|
||||||
|
return runners, r
|
|
@ -0,0 +1,70 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import trio
|
||||||
|
from trio_cdp import page, emulation, dom
|
||||||
|
|
||||||
|
from ..chromium import Chromium
|
||||||
|
from ..filters import FFmpegChain, FFmpegFilter
|
||||||
|
from .base import OBSSource
|
||||||
|
|
||||||
|
|
||||||
|
async def do_screencast(url, outfile, dims):
|
||||||
|
c = Chromium()
|
||||||
|
async with await c.connect() as conn, await c.select(conn) as sess, sess.page_enable():
|
||||||
|
# Set viewport size and transparency.
|
||||||
|
await emulation.set_device_metrics_override(
|
||||||
|
width=dims[0], height=dims[1],
|
||||||
|
device_scale_factor=1.0, mobile=False
|
||||||
|
)
|
||||||
|
await emulation.set_default_background_color_override(dom.RGBA(0, 0, 0, 0))
|
||||||
|
|
||||||
|
# Navigate to the website.
|
||||||
|
async with sess.wait_for(page.LoadEventFired):
|
||||||
|
await page.navigate(url)
|
||||||
|
|
||||||
|
# Start capture.
|
||||||
|
await page.start_screencast(format_='jpeg', quality=60)
|
||||||
|
async for frame in sess.listen(page.ScreencastFrame):
|
||||||
|
outfile.write(base64.b64decode(frame.data))
|
||||||
|
await page.screencast_frame_ack(frame.session_id)
|
||||||
|
|
||||||
|
def capture_page(url, outfile, dims=(1280, 720)):
|
||||||
|
trio.run(do_screencast, url, outfile, dims, restrict_keyboard_interrupt_to_checkpoints=True)
|
||||||
|
|
||||||
|
|
||||||
|
@OBSSource.type('browser')
|
||||||
|
class OBSBrowserSource(OBSSource):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.local = False
|
||||||
|
self.url = None
|
||||||
|
self.width = 1280
|
||||||
|
self.height = 720
|
||||||
|
self.audio = False
|
||||||
|
self.tempdir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
shutil.rmtree(self.tempdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def load(self, data):
|
||||||
|
self.name = data['name']
|
||||||
|
self.local = data['settings']['is_local_file']
|
||||||
|
if self.local:
|
||||||
|
self.url = data['settings']['local_file']
|
||||||
|
else:
|
||||||
|
self.url = data['settings']['url']
|
||||||
|
self.width = data['settings']['width']
|
||||||
|
self.height = data['settings']['height']
|
||||||
|
self.audio = data['settings']['reroute_audio']
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
fifo_path = os.path.join(self.tempdir, 'frame.bin')
|
||||||
|
fifo = os.mkfifo(fifo_path)
|
||||||
|
runner = functools.partial(capture_page, url=self.url, outfile=fifo_path, dims=(self.width, self.height))
|
||||||
|
return [runner], [FFmpegChain(
|
||||||
|
FFmpegFilter('movie', filename=fifo_path, loop=0)
|
||||||
|
)]
|
|
@ -0,0 +1,72 @@
|
||||||
|
import math
|
||||||
|
import collections
|
||||||
|
import string
|
||||||
|
|
||||||
|
from ..filters import FFmpegGraph, FFmpegChain, FFmpegFilter
|
||||||
|
from .base import OBSSource
|
||||||
|
|
||||||
|
|
||||||
|
IDENTIFY_MAPPING = collections.defaultdict(lambda: '_', {ord(k): k for k in string.ascii_letters + string.digits})
|
||||||
|
|
||||||
|
def identifiery(n):
|
||||||
|
return n.translate(IDENTIFY_MAPPING)
|
||||||
|
|
||||||
|
def deg2rad(n):
|
||||||
|
return n * (math.pi / 180)
|
||||||
|
|
||||||
|
|
||||||
|
@OBSSource.type('scene')
|
||||||
|
class OBSSceneSource(OBSSource):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.name = None
|
||||||
|
self.items = []
|
||||||
|
|
||||||
|
def load(self, data):
|
||||||
|
self.name = data['name']
|
||||||
|
self.items = data['settings']['items']
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
prefix = identifiery(self.name)
|
||||||
|
i = 1
|
||||||
|
graph = [FFmpegChain(
|
||||||
|
FFmpegFilter('color', c='black', s='{}x{}'.format(*scene.dimensions), outs=[prefix + str(i)])
|
||||||
|
)]
|
||||||
|
runners = []
|
||||||
|
|
||||||
|
for item in self.items:
|
||||||
|
if not item['visible']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rs, chains = scene.sources[item['name']].to_ffmpeg(scene)
|
||||||
|
runners.extend(rs)
|
||||||
|
c = chains[-1]
|
||||||
|
|
||||||
|
if item['scale']['x'] != 1.0 or item['scale']['y'] != 1.0:
|
||||||
|
c.append(FFmpegFilter('scale',
|
||||||
|
w='{} * in_w'.format(item['scale']['x']),
|
||||||
|
h='{} * in_h'.format(item['scale']['y'])
|
||||||
|
))
|
||||||
|
if item['crop_top'] != 0 or item['crop_bottom'] != 0 or item['crop_left'] or item['crop_right'] != 0:
|
||||||
|
c.append(FFmpegFilter('crop',
|
||||||
|
w='in_w - {} - {}'.format(item['crop_left'], item['crop_right']),
|
||||||
|
h='in_h - {} - {}'.format(item['crop_top'], item['crop_bottom']),
|
||||||
|
x=item['crop_left'],
|
||||||
|
y=item['crop_top']
|
||||||
|
))
|
||||||
|
if item['rot'] != 0.0:
|
||||||
|
c.append(FFmpegFilter('rotate',
|
||||||
|
angle=deg2rad(item['rot'])
|
||||||
|
))
|
||||||
|
c.append(FFmpegFilter('overlay',
|
||||||
|
eval='init',
|
||||||
|
x=item['pos']['x'],
|
||||||
|
y=item['pos']['y'],
|
||||||
|
ins=[prefix + str(i)],
|
||||||
|
outs=[prefix + str(i + 1)])
|
||||||
|
)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
graph.extend(chains)
|
||||||
|
|
||||||
|
return runners, graph
|
|
@ -0,0 +1,40 @@
|
||||||
|
from ..filters import FFmpegChain, FFmpegFilter
|
||||||
|
from .base import OBSSource
|
||||||
|
|
||||||
|
|
||||||
|
class TextSource(OBSSource):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.font = None
|
||||||
|
self.size = 32
|
||||||
|
self.color = '#ffffff'
|
||||||
|
self.contents = ''
|
||||||
|
self.shadow = 0
|
||||||
|
self.shadow_color = '#000000'
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
return [], [FFmpegChain(
|
||||||
|
FFmpegFilter('drawtext',
|
||||||
|
expansion='none',
|
||||||
|
fontcolor=self.color,
|
||||||
|
font=self.font,
|
||||||
|
text=self.contents,
|
||||||
|
shadowcolor=self.shadow_color,
|
||||||
|
shadowx=self.shadow,
|
||||||
|
shadowy=self.shadow,
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
|
||||||
|
@OBSSource.type('text_gdiplus')
|
||||||
|
class GDIPlusSource(TextSource):
|
||||||
|
def load(self, data):
|
||||||
|
self.font = data['settings']['font']['face']
|
||||||
|
self.size = data['settings']['font']['size']
|
||||||
|
self.color = data['settings']['color']
|
||||||
|
|
||||||
|
@OBSSource.type('text_ft2_source')
|
||||||
|
class FreeType2Source(TextSource):
|
||||||
|
def load(self, data):
|
||||||
|
self.font = data['settings']['font']['face']
|
||||||
|
self.size = data['settings']['font']['size']
|
||||||
|
self.color = data['settings']['color0']
|
|
@ -0,0 +1,34 @@
|
||||||
|
from ..filters import FFmpegChain, FFmpegFilter
|
||||||
|
from .base import OBSSource
|
||||||
|
|
||||||
|
|
||||||
|
@OBSSource.type('image_source')
|
||||||
|
class ImageSource(OBSSource):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.infile = None
|
||||||
|
|
||||||
|
def load(self, data):
|
||||||
|
self.infile = data['settings']['file']
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
return [], [FFmpegChain(
|
||||||
|
FFmpegFilter('movie', filename=self.infile)
|
||||||
|
)]
|
||||||
|
|
||||||
|
@OBSSource.type('ffmpeg_source')
|
||||||
|
class FFmpegSource(OBSSource):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.infile = None
|
||||||
|
self.loop = False
|
||||||
|
|
||||||
|
def load(self, data):
|
||||||
|
self.infile = data['settings']['local_file']
|
||||||
|
self.loop = data['settings']['looping']
|
||||||
|
|
||||||
|
def to_ffmpeg(self, scene):
|
||||||
|
return [], [FFmpegChain(
|
||||||
|
FFmpegFilter('movie', filename=self.infile, loop=0 if self.loop else 1),
|
||||||
|
FFmpegFilter('realtime')
|
||||||
|
)]
|
|
@ -0,0 +1,69 @@
|
||||||
|
import twitchio.ext.commands
|
||||||
|
import collections
|
||||||
|
import zmq
|
||||||
|
|
||||||
|
from .filters import FFmpegChain, FFmpegFilter, filter_escape
|
||||||
|
|
||||||
|
|
||||||
|
def make_twitch_chat(name, nickname, token, channel, x=200, y=25, addr='tcp://localhost:5555', nick_width=160, nick_args={}, chat_width=300, chat_args={}):
|
||||||
|
nick_id = 'drawtext@twitch-{}-nicks'.format(name)
|
||||||
|
chat_id = 'drawtext@twitch-{}-messages'.format(name)
|
||||||
|
runner = TwitchChatUpdater(nickname=nickname, token=token, channel=channel, nick_target=nick_id, chat_target=chat_id)
|
||||||
|
return [runner.run], [FFmpegChain(
|
||||||
|
FFmpegFilter(nick_id,
|
||||||
|
text='Loading',
|
||||||
|
expansion='none',
|
||||||
|
x='{} + max(0, {} - text_w)'.format(x, nick_width),
|
||||||
|
y=y,
|
||||||
|
**nick_args
|
||||||
|
),
|
||||||
|
FFmpegFilter(chat_id,
|
||||||
|
text='chat...',
|
||||||
|
expansion='none',
|
||||||
|
x='{} + {}'.format(x, nick_width),
|
||||||
|
y=y,
|
||||||
|
**chat_args
|
||||||
|
),
|
||||||
|
FFmpegFilter('zmq')
|
||||||
|
)]
|
||||||
|
|
||||||
|
def zmq_escape(v):
|
||||||
|
return (filter_escape(v)
|
||||||
|
.replace('\\', '\\\\')
|
||||||
|
.replace('\n', '\\\n')
|
||||||
|
.replace(' ', '\\ ')
|
||||||
|
)
|
||||||
|
|
||||||
|
class TwitchChatUpdater(twitchio.ext.commands.Bot):
|
||||||
|
def __init__(self, nickname, token, channel, nick_target, chat_target, addr='tcp://localhost:5555', maxlen=15):
|
||||||
|
super().__init__(irc_token=token, nick=nickname, initial_channels=[channel], prefix='kwdakwdwajd')
|
||||||
|
self.zmq_messages = collections.deque(maxlen=maxlen)
|
||||||
|
self.zmq = zmq.Context()
|
||||||
|
self.zmq_sock = self.zmq.socket(zmq.REQ)
|
||||||
|
self.zmq_sock.connect(addr)
|
||||||
|
self.zmq_nick_target = nick_target
|
||||||
|
self.zmq_chat_target = chat_target
|
||||||
|
|
||||||
|
def zmq_send_command(self, target, command, **args):
|
||||||
|
cmd = '{} {} {}'.format(
|
||||||
|
target, command,
|
||||||
|
':'.join(
|
||||||
|
'{}={}'.format(k, zmq_escape(v)) for k, v in args.items()
|
||||||
|
)
|
||||||
|
).encode('utf-8')
|
||||||
|
self.zmq_sock.send(cmd)
|
||||||
|
resp = self.zmq_sock.recv()
|
||||||
|
|
||||||
|
def zmq_update(self):
|
||||||
|
nick_text = '\n'.join('<{}>'.format(m.author.name) for m in self.zmq_messages if m)
|
||||||
|
self.zmq_send_command(self.zmq_nick_target, 'reinit', text=nick_text)
|
||||||
|
chat_text = '\n'.join(m.content for m in self.zmq_messages if m)
|
||||||
|
self.zmq_send_command(self.zmq_chat_target, 'reinit', text=chat_text)
|
||||||
|
|
||||||
|
async def event_ready(self):
|
||||||
|
self.zmq_update()
|
||||||
|
|
||||||
|
async def event_message(self, message):
|
||||||
|
print(message.tags)
|
||||||
|
self.zmq_messages.append(message)
|
||||||
|
self.zmq_update()
|
Loading…
Reference in New Issue