The Google Chromecast is an impressive little device. If you haven't encountered one already, it's a small HDMI dongle which, when connected to a TV screen, allows to play audio, video, or visual content of a compatible webapp from a computer or mobile device.
However, it is primarily designed to only stream content from the Web, and not from your computer itself, which follows the current trend that everything should be "in the cloud" and is infuriatingly limiting. As you can guess, that dubious ideology is not my cup of tea.
Luckily, the excellent library PyChromecast allows to control the device from a Python program. Yet the issue is that it only works for codecs the Chromecast is able to decode natively, i.e., H.264 and VP8. Besides, the Chromecast is only able to handle a few containers like MP4 and WebM. What if you want to stream other video formats ? Besides, what if you want to stream dynamically-generated content, for instance your screen or a live video from a camera ?
Introducing ffmpeg!
ffmpeg -i test.avi -c:v libvpx -c:a libvorbis -f webm out.webm
In this example, ffmpeg reads test.avi, recodes the video stream as VP8 and the audio stream as Vorbis, encapsulates the streams in WebM format and outputs in out.webm.
We can enhance this command for streaming to the Chromecast. In particular, ffmpeg allows video filters with -vf, and also various parameters to tune the VP8 codec. Here, we want constant-bitrate realtime encoding with a bound on 50% CPU (0 is 100% and 15 is minimum here). Here, the target bitrate is set to 4Mbps so it fits a crappy Wifi link, but you could set it higher, to 8Mbps for instance.
ffmpeg \
-ss 00:00:00 \
-i test.avi -copyts \
-vf "scale=-1:min(ih*1920/iw\,1080),pad=1920:1080:(1920-iw)/2:(1080-ih)/2:black" \
-c:v libvpx -b:v 4M \
-crf 16 -quality realtime -cpu-used 8 \
-c:a libvorbis \
-f webm \
out.webm
The video filter is a bit obscure to read but it boils down to two actions:
- Scale the video uniformly until either its width fits the width of the screen or its height fits the height of the screen
- Pad the scaled video with black so it is centered and the output size is the size of the screen
However, this creates a new problem. What about subtitles ? If a video has a subtitles track, they are now ignored and you can't see them. The simplest solution is to just hardcode subtitles in the streamed video, whether the video uses a subtitles track or an external subtitles file.
You might have been wondering, why bother with padding ? That's your answer: since we hardcode subtitles, we want them to take advantage of the padding so they cover the image as little as possible.
We can call ffmpeg from Python code with the subprocess module, while directing its output to the standard output (with -). It is good practice to use arrays to specify arguments rather than passing the command as a string with shell=True. The latter can be a security hazard since it allows shell injection.
#!/usr/bin/env python3
import sys
import subprocess
import os.path
filename = 'test.avi'
startTime = '00:00:00'
stopTime = ''
filters = ['scale=-1:min(ih*1920/iw\,1080)', 'pad=1920:1080:(1920-iw)/2:(1080-ih)/2:black']
srt = os.path.splitext(filename)[0]+'.srt'
if os.path.isfile(srt):
filters+= ['subtitles='+srt+':charenc=CP1252']
elif 'codec_type=subtitle' in subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', filename]):
filters+= ['subtitles='+filename]
args = ['ffmpeg']
if len(startTime):
args+= ['-ss', startTime]
args+= ['-i', filename]
if len(stopTime):
args+= ['-to', stopTime]
args+= ['-vf', ','.join(filters)]
args+= ['-v', 'error', '-copyts', '-c:v', 'libvpx', '-b:v', '4M', '-crf', '16', '-quality', 'realtime', '-cpu-used', '8', '-c:a', 'libvorbis', '-f', 'webm', '-']
subprocess.call(args, stdin=None, stdout=sys.stdout, stderr=None, shell=False)
The next step is to encapsulate this code in an HTTP server to stream the encoded WebM file over HTTP. In bonus, the special URL screen will allow to stream the screen content!
#!/usr/bin/env python3
import sys
import subprocess
import os.path
import urllib
import threading
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
defaultServerPort = 8888
directory = '/home/chapelierfou/videos' # The directory where my videos are
screenDisplay = ':0.0' # X11 display
screenAudio = 'default' # Pulseaudio interface
class RequestHandler(BaseHTTPRequestHandler):
# GET
def do_GET(self):
filename = urllib.parse.unquote(urllib.parse.urlparse(self.path).path)
query = dict((qc.split('=') if '=' in qc else [qc, '']) for qc in urllib.parse.urlparse(self.path).query.split('&'))
if filename != '/screen':
filename = directory + filename
if not os.path.isfile(filename):
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header('Content-type', 'video/webm')
self.end_headers()
filters = ['scale=-1:min(ih*1920/iw\,1080)', 'pad=1920:1080:(1920-iw)/2:(1080-ih)/2:black']
srt = os.path.splitext(filename)[0]+'.srt'
if os.path.isfile(srt):
filters+= ['subtitles='+srt+':charenc=CP1252']
elif 'codec_type=subtitle' in subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', filename]).decode():
filters+= ['subtitles='+filename]
args = ['ffmpeg']
if 'start' in query:
args+= ['-ss', query['start']]
if filename == "/screen":
args+= ['-avioflags', 'direct', '-fflags', 'nobuffer']
args+= ['-video_size', '1920x1080', '-framerate', '25', '-f', 'x11grab', '-i', screenDisplay, '-f', 'pulse', '-ac', '2', '-i', screenAudio]
else:
args+= ['-i', filename]
if 'stop' in query:
args+= ['-to', query['stop']]
if len(filters):
args+= ['-vf', ','.join(filters)]
args+= ['-v', 'error', '-copyts', '-c:v', 'libvpx', '-b:v', '4M', '-crf', '10', '-quality', 'realtime', '-cpu-used', '8', '-c:a', 'libvorbis', '-f', 'webm', '-']
subprocess.call(args, stdin=None, stdout=self.wfile, stderr=None, shell=False)
return
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
pass
def run():
if len(sys.argv) >= 2:
serverPort = int(sys.argv[1])
else:
serverPort = defaultServerPort
serverAddress = ('0.0.0.0', defaultServerPort)
httpd = ThreadedHTTPServer(serverAddress, RequestHandler)
httpd.serve_forever()
run()
Let's launch the server:
$ ./server.py 8888 &
Finally, we need a small command-line client with PyChromecast to start the video.
#!/usr/bin/env python3
import time
import sys
import logging
import subprocess
import pychromecast
import optparse
import json
defaultRootUrl = 'http://192.168.0.X:8888/' # Address of the server
parser = optparse.OptionParser()
parser.add_option("-d", "--device", dest="name",
help="send to NAME", metavar="NAME")
parser.add_option("-t", "--type", dest="type", default="BUFFERED",
help="set stream to TYPE", metavar="TYPE")
parser.add_option("-l", "--list",
action="store_true", dest="list", default=False,
help="list names")
(options, args) = parser.parse_args()
if options.list:
print(json.dumps({'devices': list(pychromecast.get_chromecasts_as_dict().keys())}));
exit(0)
if options.name:
cast = pychromecast.get_chromecast(friendly_name=options.name);
else:
cast = pychromecast.get_chromecast();
cast.wait()
if not cast.is_idle:
cast.quit_app()
time.sleep(1)
if len(args) == 0:
print(json.dumps({'device': cast.device.friendly_name}))
exit(0)
if "://" in args[0]:
url = args[0]
else:
url = defaultRootUrl+args[0]
print(json.dumps({'device': cast.device.friendly_name, 'url': url}))
cast.play_media(url, "video/webm", stream_type=options.type)
cast.media_controller.enable_subtitle(0);
exit(0)
Now, let the magic happen:
$ ./play.py Elephants_Dream_HD.avi