├── README.md ├── extender.jpg ├── hdmi-rip.py └── setup.py /README.md: -------------------------------------------------------------------------------- 1 | # hdmi-rip 2 | Video/Audio ripper for HDMI IP network extender. 3 | 4 | This code will take the output of a LENKENG HDMI over IP extender (Version 2.0) and save it to raw audio (signed 32 bit big-endian PCM) and MJPEG video (YUV 4:2:2). 5 | 6 | The hardware can be bought cheaply on ebay by searching for 'HDMI Extender 120m', and the device should look like this: 7 | 8 | ![LAN extender](extender.jpg) 9 | 10 | Make sure it says "LAN/TX" on the CAT5 socket, not just TX as there are some models that do not use IP, but only a straight cable connection. Also, make sure it is the V2.0 model, not V3. The receiver is not required so if you can find a TX only unit cheaper, all the better. 11 | 12 | Despite the cheapness of the device, the video output is very high quality. This is because it is effectively dumping a frame buffer direct from HDMI and is using the YUV 4:2:2 Chroma scheme so there is very little noticeable degredation. The audio, on the other hand, is always transcoded to raw PCM Stereo, so you will lose any surround sound etc. 13 | 14 | ##Installation: 15 | 16 | ``` 17 | 18 | sudo python ./setup.py install 19 | 20 | ``` 21 | 22 | ##Usage: 23 | 24 | ``` 25 | 26 | hdmi-rip.py [options] [minutes] 27 | 28 | Options: 29 | -h, --help show this help message and exit 30 | -a AUDIO_RATE, --audio_rate=AUDIO_RATE 31 | audio data rate in Hz (48000) 32 | -c AUDIO_CHANNELS, --audio_channels=AUDIO_CHANNELS 33 | audio channels (2) 34 | -f FRAME_RATE, --frame_rate=FRAME_RATE 35 | video frame rate (29.97) 36 | -H HEIGHT, --height=HEIGHT 37 | monitor window height (540) 38 | -k, --keyboard start/stop recording under keyboard control (False) 39 | -l LOCAL_IP, --local_ip=LOCAL_IP 40 | use local IP address as source (0.0.0.0) 41 | -n, --no_monitor do not monitor video in pop-up window (False) 42 | -p SENDER_PORT, --sender_port=SENDER_PORT 43 | set sender's UDP PORT (48689) 44 | -q, --quiet do not print status messages to stdout (False) 45 | -s SENDER_IP, --sender_ip=SENDER_IP 46 | set sender's IP address (192.168.168.55) 47 | -S, --strict strict mode - abort recording if frames dropped 48 | (False) 49 | -w, --wave save audio in .wav format (False) 50 | -W WIDTH, --width=WIDTH 51 | monitor window width (960) 52 | 53 | ``` 54 | 55 | You will need to run as root to be able create the appropriate network sockets, and have a local IP address on the same network as the sender. You must also have a default route set in order to be able to join the multicast group. 56 | 57 | ##Example: 58 | 59 | ``` 60 | 61 | sudo ifconfig eth0:1 inet 192.168.168.1 62 | 63 | if direct connection: 64 | 65 | sudo route add default gw 192.168.168.1 66 | 67 | sudo hdmi-rip.py /tmp/dummy 20 68 | 69 | ``` 70 | 71 | Will produce /tmp/dummy-video.dat and /tmp/dummy-audio.dat and automatically stop after 20 minutes. 72 | 73 | ##Transcoding: 74 | 75 | I do this in two stages. The first synchronises the audio but leaves the video alone which speeds up processing. This output is then used for editing/trimming. Adjust the audio offset with '-itsoffset' 76 | 77 | For NTSC content no audio offset: 78 | 79 | ``` 80 | 81 | ffmpeg -f mjpeg -r 29.97 -i /tmp/dummy-video.dat -itsoffset 0.0 -f s32be -ac 2 -ar 48000 -i /tmp/dummy-audio.dat -f matroska -vcodec copy -c:a libmp3lame -qscale:a 2 /tmp/dummy.mkv 82 | 83 | ``` 84 | 85 | For PAL content, audio delayed by 0.2 seconds: 86 | 87 | ``` 88 | 89 | ffmpeg -f mjpeg -r 25 -i /tmp/dummy-video.dat -itsoffset 0.2 -f s32be -ac 2 -ar 48000 -i /tmp/dummy-audio.dat -f matroska -vcodec copy -c:a libmp3lame -qscale:a 2 /tmp/dummy.mkv 90 | 91 | ``` 92 | 93 | I use kdenlive to trim: add clip to project then select entry and exit points in clip monitor and right-click to 'extract zone' for a lossless edit. 94 | 95 | Once trimmed, transcode the video: 96 | 97 | ``` 98 | 99 | ffmpeg -i /tmp/dummy.mkv -c:v mpeg4 -vtag xvid -qscale:v 2 -c:a copy /tmp/final.mkv 100 | 101 | ``` 102 | 103 | The final output of a 720p capture of Big Buck Bunny, (c) copyright 2008, Blender Foundation / www.bigbuckbunny.org, can be viewed here: 104 | 105 | Big Buck Bunny 106 | 107 | ##Tips & tricks: 108 | 109 | Reduce packet loss by using a direct connection instead of a switch (don't forget to set a dummy default route or you won't be able to join the multicast group). 110 | 111 | Seemingly unrelated actions like heavy load from other processes or even screensavers can cause packet loss. 112 | 113 | Adding a dual output HDMI splitter will allow you to monitor the source while setting up for recording. It also prevents auto-switching of HDMI settings sometimes caused by switching cables. 114 | 115 | You may get a better soundtrack by switching your source to Stereo/PCM instead of allowing the transmitter to downscale (if you use a home-theater amp, you'll need to configure the amp instead and only send Stereo/PCM audio to the TV so you can tap that output with the splitter and capture from any source). 116 | 117 | ##Further development you could help with: 118 | 119 | - Add support for other senders. 120 | 121 | ##Credits: 122 | 123 | This project was inspired by benjojo's blog: https://blog.benjojo.co.uk/post/cheap-hdmi-capture-for-linux which was in turn inspired by danman's blog: https://blog.danman.eu/reverse-engineering-lenkeng-hdmi-over-ip-extender/ 124 | 125 | Original script by Silver Moon (m00n.silv3r@gmail.com) and danman. 126 | 127 | Framerates by Jared Earl. :) 128 | 129 | ##Notes: 130 | 131 | Video frames are buffered and discarded if any part of the frame is lost or received out of sequence. This is safe to do as the data is in MJPEG format so every frame is a complete image, and loss of one frame every now and then will not be noticeable. The same cannot be said for audio as although it is discarded in the same way (and also during video packet loss to maintain sync) the effect is definitely more noticeable. 132 | 133 | The audio track is currently not tagged with its data rate, so you may need to experiment to decide what it is. The only format I've seen so far is 48KHz which gives the above video framerates for transcoding. If your soundtrack in the monitor window goes out of sync then you've probably used the wrong one (despite being in the UK, a lot of streaming services are actually transmitted with NTSC framerates). Any changes you make to the monitoring settings will have to be re-applied during transcoding as the output files are still the original raw stream. 134 | 135 | HDCP is stripped by the hardware. Why this is not a surprise: http://adamsblog.aperturelabs.com/2013/02/hdcp-is-dead-long-live-hdcp-peek-into.html 136 | 137 | YMMV: the output quality appears to be quite dependant on the input format. I've seen 1080P rips that are almost indistinguishable from the original, and yet the 720P Big Buck Bunny rip above has noticeable artifacts which are introduced by the sender, not the 2nd stage transcoding. 138 | 139 | ##Copyrighted material 140 | 141 | Where would we be without the usual waffle about copyright? 142 | 143 | In the UK we have a thing called "Time-shifting", the details of which can be viewed here: https://www.gov.uk/guidance/exceptions-to-copyright 144 | 145 | In my view, this gives me the right to record anything and everything as long as I don't re-distribute it and only use it within my own houeshold/family non-commercially. However, I'm not a lawyer so take your own advice and bear in mind that kittens, unicorns and/or other mythical creatures may be harmed if you do not respect Copyright in both letter and spirit, and you may make the baby Jesus cry. 146 | 147 | For clarity: this code is published for the sole purpose of legally recording content that has had NotVeryGood(tm) copy-protection applied to it. Please do not abuse that facility, and enjoy your recordings in the comfort and safety of your own home(s). 148 | -------------------------------------------------------------------------------- /extender.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AdamLaurie/hdmi-rip/77742c379051f749e31e56f04fb143bc783d11d8/extender.jpg -------------------------------------------------------------------------------- /hdmi-rip.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | # HDMI to IP network extender ripper 4 | 5 | # Adam Laurie - 2016 6 | # 7 | # https://github.com/AdamLaurie/hdmi-mjpeg 8 | # 9 | # 1st cut: 15th July 2016 10 | # 11 | # code based on original: 12 | # 13 | #Packet sniffer in python 14 | #For Linux - Sniffs all incoming and outgoing packets :) 15 | #Silver Moon (m00n.silv3r@gmail.com) 16 | #modified by danman 17 | 18 | 19 | import signal 20 | import socket, sys, os 21 | from struct import * 22 | import struct 23 | import binascii 24 | import time, datetime 25 | from optparse import OptionParser 26 | import wave 27 | import select 28 | 29 | 30 | MESSAGE = "5446367A600200000000000303010026000000000234C2".decode('hex') 31 | AUDIO_HEADER= "00555555555555555555555500000000".decode('hex') 32 | exitvalue= os.EX_OK 33 | 34 | 35 | usage= "usage: %prog [options] [minutes]" 36 | parser= OptionParser(usage= usage) 37 | parser.add_option("-a", "--audio_rate", type="int", dest="audio_rate", default= 48000, help="audio data rate in Hz (48000)") 38 | parser.add_option("-c", "--audio_channels", type="int", dest="audio_channels", default= 2, help="audio channels (2)") 39 | parser.add_option("-f", "--frame_rate", type="float", dest="frame_rate", default= 29.97, help="video frame rate (29.97)") 40 | parser.add_option("-H", "--height", type="int", dest="height", default= 540, help="monitor window height (540)") 41 | parser.add_option("-k", "--keyboard", action="store_true", dest="keyboard", default= False, help="start/stop recording under keyboard control (False)") 42 | parser.add_option("-l", "--local_ip", dest="local_ip", default= "0.0.0.0", help="use local IP address as source (0.0.0.0)") 43 | parser.add_option("-n", "--no_monitor", action="store_false", dest="monitor", default= True, help="do not monitor video in pop-up window (False)") 44 | parser.add_option("-p", "--sender_port", type="int", dest="sender_port", default= 48689, help="set sender's UDP PORT (48689)") 45 | parser.add_option("-q", "--quiet", action="store_true", dest="quiet", default= False, help="do not print status messages to stdout (False)") 46 | parser.add_option("-s", "--sender_ip", dest="sender_ip", default= "192.168.168.55", help="set sender's IP address (192.168.168.55)") 47 | parser.add_option("-S", "--strict", action="store_true", dest="strict", default= False, help="strict mode - abort recording if frames dropped (False)") 48 | parser.add_option("-w", "--wave", action="store_true", dest="wave", default= False, help="save audio in .wav format (False)") 49 | parser.add_option("-W", "--width", type="int", dest="width", default= 960, help="monitor window width (960)") 50 | (options, args) = parser.parse_args() 51 | 52 | if not (len(args) == 1 or len(args) == 2): 53 | parser.print_help() 54 | exit(0) 55 | 56 | def log(message): 57 | if options.quiet: 58 | return 59 | print message 60 | 61 | def signal_handler(signal, frame): 62 | if options.monitor: 63 | pipeline.send_event(gst.Event.new_eos()) 64 | log('\nFlushing buffers...') 65 | if not options.wave: 66 | Audio.flush() 67 | os.fsync(Audio.fileno()) 68 | Audio.close() 69 | log('Audio: %d frames, %d bytes (%d packets dropped)' % ((Audio_Bytes / 4) / options.audio_channels, Audio_Bytes, Audio_Dropped)) 70 | Video.flush() 71 | os.fsync(Video.fileno()) 72 | Video.close() 73 | log('Video: %d frames, %d bytes (%d frames dropped)' % (Video_Frames, Video_Bytes, Video_Dropped)) 74 | sys.exit(exitvalue) 75 | 76 | def keepalive(): 77 | Keepalive_sock.sendto(MESSAGE, (options.sender_ip, options.sender_port)) 78 | 79 | #Convert a string of 6 characters of ethernet address into a dash separated hex string 80 | def eth_addr (a) : 81 | b = "%.2x:%.2x:%.2x:%.2x:%.2x:%.2x" % (ord(a[0]) , ord(a[1]) , ord(a[2]), ord(a[3]), ord(a[4]) , ord(a[5])) 82 | return b 83 | 84 | # set up for keypress detection 85 | if options.keyboard: 86 | recording= False 87 | else: 88 | recording= True 89 | 90 | # launch monitor 91 | if options.monitor: 92 | import gi 93 | gi.require_version('Gst', '1.0') 94 | from gi.repository import GObject,Gtk 95 | from gi.repository import Gst as gst 96 | 97 | GObject.threads_init() 98 | gst.init() 99 | pipeline= gst.parse_launch('appsrc name=audio_source emit-signals=false is-live=true ! queue ! audio/x-raw,format=S32BE,channels=%d,rate=%d ! autoaudiosink appsrc name=video_source emit-signals=false is-live=true ! queue ! image/jpeg,framerate=%d/100 ! jpegparse ! jpegdec ! videoconvert ! videoscale ! video/x-raw,width=%d,height=%d ! autovideosink' % (options.audio_channels, options.audio_rate, options.frame_rate * 100, options.width, options.height)) 100 | audio_source= pipeline.get_by_name("audio_source") 101 | video_source= pipeline.get_by_name("video_source") 102 | pipeline.set_state(gst.State.READY) 103 | 104 | log("UDP target IP: %s" % options.sender_ip) 105 | log("UDP keepalive port: %d" % options.sender_port) 106 | 107 | try: 108 | record_time= int(args[1]) 109 | log('Recording will cease after %d minutes' % record_time) 110 | except: 111 | record_time= None 112 | end_time= None 113 | if options.strict: 114 | log('Recording will be aborted if any frame is dropped') 115 | 116 | 117 | Keepalive_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket for keepalives 118 | Keepalive_sock.bind((options.local_ip, options.sender_port)) # send from the correct port or it will be ignored 119 | 120 | # detect quit 121 | signal.signal(signal.SIGINT, signal_handler) 122 | 123 | try: 124 | s = socket.socket( socket.AF_PACKET , socket.SOCK_RAW , socket.ntohs(0x0003)) 125 | except socket.error , msg: 126 | print 'Socket could not be created. Error Code : ' + str(msg[0]) + ' Message ' + msg[1] 127 | sys.exit() 128 | 129 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 130 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 131 | mreq = struct.pack("=4sl", socket.inet_aton("226.2.2.2"), socket.INADDR_ANY) 132 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 133 | 134 | sender="000b78006001".decode("hex") 135 | Videostarted=0 136 | 137 | if options.wave: 138 | audiofile= args[0] + "-audio.wav" 139 | Audio=wave.open(audiofile,"w") 140 | Audio.setnchannels(options.audio_channels) 141 | Audio.setsampwidth(4) 142 | Audio.setframerate(options.audio_rate) 143 | log('Audio: %s' % audiofile) 144 | else: 145 | audiofile= args[0] + "-audio.dat" 146 | Audio= open(audiofile,"w") 147 | log('Audio: %s' % audiofile) 148 | videofile= args[0] + "-video.dat" 149 | Video= open(videofile,"w") 150 | log('Video: %s' % videofile) 151 | Video_Frames= 0 152 | Video_Bytes= 0 153 | Video_Dropped= 0 154 | Audio_Bytes= 0 155 | Audio_Dropped= 0 156 | 157 | # keep track of dropped frames 158 | frame_prev= None 159 | part_prev= 0 160 | 161 | packet_started= False 162 | senderstarted= False 163 | video_buf= '' 164 | audio_buf= '' 165 | audio_buf_frames= 0 166 | dropping= False 167 | 168 | # receive a packet 169 | while True: 170 | packet = s.recvfrom(65565) 171 | 172 | if not packet_started: 173 | log('Listener active at %s' % datetime.datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S')) 174 | packet_started= True 175 | 176 | #packet string from tuple 177 | packet = packet[0] 178 | 179 | #parse ethernet header 180 | eth_length = 14 181 | 182 | eth_header = packet[:eth_length] 183 | eth = unpack('!6s6sH' , eth_header) 184 | eth_protocol = socket.ntohs(eth[2]) 185 | 186 | if (packet[6:12] == sender) & (eth_protocol == 8) : 187 | 188 | #Parse IP header 189 | #take first 20 characters for the ip header 190 | ip_header = packet[eth_length:20+eth_length] 191 | 192 | #now unpack them :) 193 | iph = unpack('!BBHHHBBH4s4s' , ip_header) 194 | 195 | version_ihl = iph[0] 196 | version = version_ihl >> 4 197 | ihl = version_ihl & 0xF 198 | 199 | iph_length = ihl * 4 200 | 201 | ttl = iph[5] 202 | protocol = iph[6] 203 | s_addr = socket.inet_ntoa(iph[8]); 204 | d_addr = socket.inet_ntoa(iph[9]); 205 | 206 | #UDP packets 207 | if protocol == 17 : 208 | u = iph_length + eth_length 209 | udph_length = 8 210 | udp_header = packet[u:u+8] 211 | 212 | #now unpack them :) 213 | udph = unpack('!HHHH' , udp_header) 214 | 215 | source_port = udph[0] 216 | dest_port = udph[1] 217 | length = udph[2] 218 | checksum = udph[3] 219 | 220 | #get data from the packet 221 | h_size = eth_length + iph_length + udph_length 222 | data = packet[h_size:] 223 | 224 | # audio 225 | if (dest_port==2066) and Videostarted: 226 | if data[:16] == AUDIO_HEADER: 227 | audio_buf += data[16:] 228 | audio_buf_frames += 1 229 | else: 230 | log('Audio frame dropped') 231 | dropping= True 232 | if options.monitor: 233 | pipeline.set_state(gst.State.READY) 234 | if options.strict: 235 | log('Aborting due to audio frame drop!') 236 | exitvalue= os.EX_DATAERR 237 | os.kill(os.getpid(), signal.SIGINT) 238 | 239 | # video 240 | if (dest_port==2068): 241 | if options.keyboard: 242 | if len(select.select([sys.stdin],[],[],0)[0]) != 0: 243 | sys.stdin.readline() 244 | sys.stdin.flush() 245 | recording= not recording 246 | if recording: 247 | log('Recording started at: %s' % datetime.datetime.fromtimestamp(time.time())) 248 | else: 249 | log('Recording stopped at %s' % datetime.datetime.fromtimestamp(time.time())) 250 | if not senderstarted: 251 | log('Sender active at %s' % datetime.datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S')) 252 | senderstarted= True 253 | frame_n=ord(data[0])*256+ord(data[1]) 254 | # data[2] is not part of the frame number - if it is set to 0x80 that means this is the last frame 255 | #part=ord(data[2])*256+ord(data[3]) 256 | part=ord(data[3]) 257 | if (part == 0): 258 | if not Videostarted: 259 | start_time= time.time() 260 | log("Video stream started at frame %s %s" % (frame_n, datetime.datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S'))) 261 | if options.keyboard: 262 | log('Hit to START/STOP recording') 263 | log('CTL-C to exit') 264 | Videostarted= 1 265 | if record_time: 266 | end_time= record_time * 60 + start_time 267 | log('Recording will stop automatically at: %s' % datetime.datetime.fromtimestamp(end_time).strftime('%Y-%m-%d %H:%M:%S')) 268 | frame_prev= frame_n 269 | if dropping: 270 | Video_Dropped += 1 271 | Audio_Dropped += audio_buf_frames 272 | if options.strict: 273 | log('Aborting due to frame drop!') 274 | exitvalue= os.EX_DATAERR 275 | os.kill(os.getpid(), signal.SIGINT) 276 | else: 277 | if recording: 278 | Video_Frames += 1 279 | Video.write(video_buf) 280 | Video_Bytes += len(video_buf) 281 | if not Video_Frames % 100 and len(audio_buf) == 0: 282 | log("*** Warning! No AUDIO! Possible incompatible codec - set source to Stereo/PCM?") 283 | if options.wave: 284 | # write as little-endian 285 | Audio.writeframesraw(''.join([audio_buf[i:i+4][::-1] for i in range(0, len(audio_buf), 4)])) 286 | else: 287 | Audio.write(audio_buf) 288 | Audio_Bytes += len(audio_buf) 289 | if options.monitor: 290 | buf= gst.Buffer.new_allocate(None, len(video_buf), None) 291 | buf.fill(offset=0, src=video_buf) 292 | video_source.emit("push-buffer", buf) 293 | buf= gst.Buffer.new_allocate(None, len(audio_buf), None) 294 | buf.fill(offset=0, src=audio_buf) 295 | audio_source.emit("push-buffer", buf) 296 | pipeline.set_state(gst.State.PLAYING) 297 | video_buf= '' 298 | audio_buf_frames= 0 299 | audio_buf= '' 300 | dropping= False 301 | if end_time and time.time() >= end_time: 302 | log("Time's up!") 303 | os.kill(os.getpid(), signal.SIGINT) 304 | elif Videostarted: 305 | if not frame_prev == frame_n: 306 | log('Video dropped frame % d' % frame_n) 307 | frame_prev= frame_n 308 | dropping= True 309 | if options.monitor: 310 | pipeline.set_state(gst.State.READY) 311 | if not part_prev + 1 == part: 312 | log('Video dropped part %d of frame %d' % (part, frame_n)) 313 | dropping= True 314 | if options.monitor: 315 | pipeline.set_state(gst.State.READY) 316 | if Videostarted and not dropping: 317 | video_buf += data[4:] 318 | part_prev= part 319 | keepalive() 320 | 321 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup (name = 'hdmi-rip', 4 | version = '1.0', 5 | description = "Video/Audio ripper for HDMI IP network extender", 6 | author = 'Adam Laurie', 7 | author_email = 'adam@algroup.co.uk', 8 | url='https://github.com/AdamLaurie/hdmi-rip', 9 | scripts = ['hdmi-rip.py'], 10 | ) 11 | --------------------------------------------------------------------------------