P2PSP-WebRCT
Fase Final del 8 CUSL
Los días 15 y 16 de Mayo visité Sevilla, allí se celebraba la fase final del Concurso Universitario de Software Libre y el proyecto "implementación del protocolo P2PSP usando WebRTC" era uno de los 6 proyectos finalistas. Fueron dos días fantásticos, conocí gente nueva que compartía conmigo la pasión por la programación y por el software libre. Además de la exposición de los proyecto de cada participante hubo otras charlas, la organización del concurso llevó a grandes profesionales para presentarnos otros proyectos de la comunidad y contarnos cosas muy interesantes relacionadas con el Open Source. Hasta se organizó un editatón de Wikipedia en el que todos los participantes ayudamos a mejorarla. Realmente fueron dos días bastante intensos.
La sorpresa llegó el último día en la entrega de premios, la implementación del P2PSP para el navegador fue galardonada con el premio principal, le otorgaron el Premio especial de la comunidad del 8º Concurso Universitario de Software Libre. Para mi es un orgullo este reconocimiento y desde aquí quiero agradecer a todos los que de una forma u otra está implicados en este proyecto, entre ellos, mi tutor del proyecto el profesor Vicente González, la comunidad del P2PSP, el grupo SAL de la Universidad de Almería y la gente de Luxunda. Gracias también al concurso por este reconocimiento.
(En la foto: Jose María Ortíz Silva de Technosite-Fundación ONCE entregándome el premio)
Por último, me gustaría felicitar al resto de premiados y a la comunidad del concurso en general. Este concurso es una gran iniciativa para fomentar el software libre en la Universidad, desde aquí animo a todos los estudiantes a participar en la siguiente edición, ¡Aprovechad la oportunidad!
(En la foto: La comunidad presente en la fase final del 8º Concurso Universitario de Software Libre)
Las diapositivas de la presentación realizadas para el CUSL8 puedes verlas en implementación del protocolo P2PSP usando WebRTC
Recuerda que el P2PSP es un proyecto de software libre y todo el mundo es bienvenido, únete.
Streaming tradicional vs Streaming P2P
Cuando accedemos al contenido multimedia en Internet, la mayoría de las veces lo hacemos mediante Streaming, esto nos permite visualizar el contenido a medida que lo estamos descargando. Portales tan famosos como YouTube o Vimeo nos ofrecen contenido en Streaming -algunos incluso en directo- usando el modelo cliente-servidor, en este tipo de servicios el servidor envía una copia del vídeo por cada cliente que está consumiendo el contenido en ese momento, esto provoca una sobrecarga en el servidor que lo hace muy poco escalable necesitando de mucha potencia de procesamiento y un gran ancho de banda en el lado servidor. El concepto IPTV es muy similar a esto, la diferencia radica en que usan redes privadas con un gran ancho de banda reservado para este fin.
Sin embargo, existe una alternativa que permite compartir vídeo en directo mediante streaming sin necesidad de disponer de un servidor potente y gran ancho de banda, son los sistemas de Streaming P2P [P2PTV] un ejemplo es el protocolo P2PSP. En este modelo, el servidor envía una única copia del stream, para ello divide el vídeo en trozos, como si de una baraja de cartas se tratase, el servidor va repartiendo las cartas (trozos) de una en una entre los cliente hasta agotar las cartas de la baraja (vídeo). Los clientes son los encargados de repartirse dichos trozos para tener todos el vídeo completo, es la misma filosofía del P2P convencional para el intercambio de archivos. Esto nos permite tener un sistema escalable que funcionaría igual que el modelo cliente-servidor pero disponiendo de recursos limitados. Ademas, permite a los clientes aprovechar una capacidad que es desaprovechada en los sistemas basado en el modelo de streaming cliente-servidor, me refiero al ancho de banda de subida.
Si a esto le unimos que los últimos avances en la Web como la API WebRTC y Media Source Extensions (ambos borradores en proceso de estandarización) permitirán que implementaciones de este tipo se puedan llevar a cabo completamente en el navegador sin necesidad de plugins, hacen del protocolo P2PSP y en general, de todos los modelos de P2PTV una buena alternativa para emitir contenido multimedia sobre la Web en un futuro muy cercano.
Nota: Puedes encontrar una versión funcional del P2PSP junto al código fuente (escrito en python) que permite compartir contenido mediante P2P en P2PSP en Launchpad
Streaming de vídeo entre navegadores vía WebRTC Datachannel y Media Source Extensions
Hoy vamos a llevar a cabo un experimento que nos permitirá enviar un vídeo en streaming entre uno o varios navegadores Web. La aplicación Web nos permitirá cargar un fichero de vídeo en el navegador, dividirlo en bloques, enviar los bloques a otros navegadores y reproducir el vídeo en todos ellos. ¡Todo en tiempo real!
Para conseguir esto, que puedes probar en http://www.p2psp.org/webrtc-streaming/, necesitamos echar un vistazo a los elementos Datachannel de la API WebRTC y Media Source Extensions (MSE). El primero nos permite compartir datos binarios entre los navegadores a través de peer to peer y el segundo nos permite enviar los trozos de vídeo directamente a la etiqueta <video> mediante un modelo de buffering.
Nota: Puedes ver otro ejemplo de Datachannel en Entendiendo WebRTC PeerConnection con un chat multiusuario
Leyendo el fichero desde el Navegador Web
Para cargar el vídeo al navegador vamos a hacer uso de la API de archivos que nos permitirá cargar en memoria trozos del fichero de vídeo de una longitud determinada e ir enviándolos al resto de navegadores para que puedan reproducirlo en tiempo real.
function readBlob(size) { var files = document.getElementById('files').files; if (!files.length) { alert('Please select a file!'); return; } var file = files[0]; var start = size; var stop = file.size - 1; if (size>file.size) return; var reader = new FileReader(); //Se dispara cuando se ha completado la solicitud. reader.onloadend = function(evt) { //Es necesario comprobar el estado if (evt.target.readyState == FileReader.DONE) { // DONE == 2 handleChunk(evt.target.result); //Enviamos el bloque de video al reproductor (etiqueta video) sendChatMessage(evt.target.result); //Comparitmos el bloque de vídeo con el resto de navegadores (via DataChannel) } }; reader.onload = function(e) { feedIt(); //Continuamos leyendo };//Extraemos un trozo de vídeo (1024 bytes)
var blob; blob = file.slice(start, start + 1024) reader.readAsArrayBuffer(blob); }
//Solicitar leer los siguientes 1024 bytes. function feedIt(){ readBlob(chunksize); chunksize+=1024; }
Alimentando la etiqueta vídeo mediante MSE
En la versión regular de Chrome ya está disponible Media Source Extensions aunque por el momento sólo permite el uso de vídeos en formato WebM. Veámos como funciona:
function handleChunk(chunk){ //Almacenamos los bloques en un buffer temporal queue.push(chunk); current++; //Cargamos la etiqueta video con mediaSource if (current==1){ video.src = window.URL.createObjectURL(mediaSource); video.pause(); } //Alimentamos Media Source if (current>=2){ appendNextMediaSegment(mediaSource); } //Después de un pequeño buffer comenzamos a reproducir if (current==128){ video.play(); } } function onSourceOpen(videoTag, e) { var mediaSource = e.target; if (mediaSource.sourceBuffers.length > 0){ console.log("SourceBuffer.length > 0"); return; } //Indicamos el tipo de contenido de mediasource var sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vorbis,vp8"'); //Obtenemos el primer bloque en el buffer (cabecera?) var initSegment = new Uint8Array(queue.shift()); if (initSegment.length==0) { //Si el bloque está vacío hay un error mediaSource.endOfStream("network"); return; } // Añadimos el primer bloque a Media Source var firstAppendHandler = function(e) { var sourceBuffer = e.target; sourceBuffer.removeEventListener('updateend', firstAppendHandler); appendNextMediaSegment(mediaSource); }; sourceBuffer.addEventListener('updateend', firstAppendHandler); sourceBuffer.addEventListener('update', onProgress.bind(videoTag, mediaSource)); sourceBuffer.appendBuffer(initSegment); } function appendNextMediaSegment(mediaSource) { //Comprobamos si media source esta listo if (mediaSource.readyState == "closed"){ console.log("readyState is closed"); return; } // NOs aseguramos que el bloque anterior no está pendiente if (mediaSource.sourceBuffers[0].updating){ return; } //No hay nada en la cola? if (queue.length==0) { return; } //Enviamos el siguiente bloque a Media Source var mediaSegment = new Uint8Array(queue.shift()); mediaSource.sourceBuffers[0].appendBuffer(mediaSegment); } //Cada vez que Source Buffer actualize intentamos añadir el siguiente bloque function onProgress(mediaSource,e) { appendNextMediaSegment(mediaSource); }Enviando los bloques al resto de peers
Partiendo del ejemplo del Chat con WebRTC, esta parte es muy sencilla ya que sólo hay que controlar lo que se recibe y se envía desde los DataChannels:
function setupChat(i) { channel[i].onopen = function () { btnStream.disabled=false; }; //Cada vez que recibimos un bloque lo enviamos al reproductor channel[i].onmessage = function (evt) { handleChunk(evt.data); }; } //Enviamos el bloque al resto de navegadores (peers) function sendChatMessage(chunk) { for (i in peerlist){ if (peerlist[i]!=idpeer){ try{ channel[peerlist[i]].send(chunk); }catch(e){ console.log(i+" said bye!"); } } } }Nota: Puedes encontrar el código completo de este experimento en Streaming de vídeo con WebRTC. Recuerda que es un experimento y puede contener errores.
Este experimento, por el momento, sólo funciona en Google Chrome (incluido Chrome for Android)
¡Participa!, si tienes dudas o sugerencias déjalas en los comentarios.
Dando forma a la emisión de contenidos en la Web
Hoy hace un mes que el profesor Vicente González y yo regresábamos a Almería después de nuestra estancia en Munich (Alemania) para asistir como ponentes en The Fourth W3C Web and TV Workshop, el objetivo del workshop era dar forma a la próxima generación de televisión y emisión de contenidos sobre la Web, prestando especial atención a la situación actual de la tecnología y de los estándares. Fueron dos días de interesantes charlas y debates organizados por el W3C, del que hay que destacar la impecable organización y profesionalidad con la que se llevó a cabo el evento.
En nuestra exposición presentamos a los asistentes el protocolo P2PSP y sus bondades, especialmente su simplicidad. Además, y puesto que el objetivo se centraba en los estándares, expusimos algunos problemas derivados de la falta de estos, como la necesidad de un formato de vídeo universal aceptado por todos los navegadores, el soporte limitado de la API WebRTC y la necesidad de trabajar duro en MediaSource Extensions para conseguir un soporte multimedia completo en el navegador Web.
Para terminar, me gustaría aprovechar este post para agradecer la colaboración del grupo Supercomputación-Algoritmos de la Universidad de Almería y de Luxunda que hicieron posible nuestra asistencia al workshop.
Chat Multiusuario con WebRTC
Para entender un poco mejor como vamos a conseguir transmitir vídeo en el navegador mediante P2P sin necesidad de plugins gracias a WebRTC, primero necesitamos entender cómo funcionan los elementos básicos de la API que nos permitirán hacer esto, hablamos de RTCPeerConnection y RTCDataChannel. Para ello, vamos a construir un pequeño ejemplo de un chat multiusuario con el objetivo de entender como trabajar con la API WebRTC.
El chat estará compuesto por los siguientes elementos:
- Peer: Es la parte del cliente, cada usuario ejecutará un peer en su navegador. El peer lo vamos a escribir en HTML5+JavaScript+WebRTC
- Server (SignalingServer): Es la parte servidora, su misión es simple, hacer que los peer se conozcan entre sí. Sólo eso, los mensajes nunca pasarán por el servidor, estos serán intercambiados directamente entre los peers. El Server lo vamos a escribir en Python.
Nota: Si todo esto te suena a chino te recomiendo que eches un vistazo a WebRTC Comunicación en tiempo real sin plugins antes de seguir leyendo.
¡Manos a la obra!
El Server (SignalingServer)
Para que los clientes (peer) se conozca entre si es necesario hacer uso de un servidor de señalización que nos de la información necesaria de cada uno para poder establecer una comunicación directa entre ellos. Necesitamos una forma de comunicarnos con los peer que se están ejecutando en el navegador de cada usuario, algo que nos permita intercambiar la información entre el servidor y cada uno de los clientes. La forma más sencilla y directa es usar WebSocket, para ello haremos uso de la siguiente librería SimpleWebSocketServer by opiate, es software libre y nos facilita su implementación en Python.
Partiendo del módulo sólo tenemos que reescribir los meéodos handleMessage, handleConnected y handleClose para que hagan lo que nosotros búscamos, en este caso es muy sencillo, intercambiar la información de cada peer con el resto de peers.
#hanleMessage se ejecuta cuando un mensaje es recibido def handleMessage(self): #recibimos un mensaje (json) datos=str(self.data) #decodificamos el mensaje (json) try: decoded = json.loads(datos) except (ValueError, KeyError, TypeError): print "JSON format error" #Reenviamos el mensaje a resto de clientes for client in self.server.connections.itervalues(): if client != self: try: client.sendMessage(str(self.data)) except Exception as n: print n #handleConnected se ejecuta cuando un nuevo cliente se conecta def handleConnected(self): global nextid try: #enviamos al cliente su id de peer self.sendMessage(str('{"numpeer":"'+str(nextid)+'"}')) #enviamos al cliente la lista de peer actual self.sendMessage(str('{"peerlist":"'+str(peerlist)+'"}')) #agregamos el nuevo peer a la lista peerlist.append(nextid) peeridlist[self]=nextid nextid=nextid+1 except Exception as n: print n #handleClose se ejecuta cuando un cliente se desconecta def handleClose(self): #eliminamos el peer de la lista peerlist.remove(peeridlist[self]);Nota: El código completo del SignalingServer está actualizado y disponible en Launchpad -> P2PSP > Experimentos > ChatMultiusuario > Server.py
El Peer
Como ya hemos dicho, la parte del cliente se ejecuta en el navegador y toda la funcionalidad vamos a escribirla en JavaScript. A continuación sólo vamos a comentar algunas partes del código que son interesantes. Recuerda que puedes acceder al código fuente completo de este experimento en Launchpad -> WebRTCMultiPeerChat
Primero definimos el SignalingServer que será la URL donde se está ejecutando Server.py y configuration para RTCPeerConnection que será el servidor STUN que se encarga de proporcionarnos la información de red externa (IP, Puerto, etc).
var signalingChannel = new WebSocket("ws://127.0.0.1:9876/"); var configuration = {iceServers: [{ url: 'stun:stun.l.google.com:19302' }]}A continuación se muestra el resto del código necesario para el peer, está comentado para una comprensión más sencilla:
// Inicializamos las conexión // isInitiator = true o false // i= id del peer function start(isInitiator,i) { //Inicializamos RTCPeerConnection para el peer i. pcs[i] = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); // Enviar cualquier ICE candidate a los otros peer. pcs[i].onicecandidate = function (evt) { if (evt.candidate){ signalingChannel.send(JSON.stringify({ "candidate": evt.candidate , "idtransmitter":'"'+idpeer+'"', "idreceiver":'"'+i+'"'})); } }; // dejar a "negotiationneeded" generar ofertas (Offer) pcs[i].onnegotiationneeded = function () { pcs[i].createOffer(function(desc){localDescCreated(desc,pcs[i],i);}); console.log("Create and send OFFER"); } if (isInitiator) { // Crear el datachannel para ese peer channel[i] = pcs[i].createDataChannel("chat"+i); setupChat(i); } else { // Establecer el datachannel para ese peer pcs[i].ondatachannel = function (evt) { channel[i] = evt.channel; setupChat(i); }; } console.log("Saved in slot: "+i+" PeerConection: "+pcs[i]); } //Establecer localDescriction y y enviarla a los otros peer (ellos la estableceran como remoteDescription) function localDescCreated(desc,pc,i) { pc.setLocalDescription(desc, function () { console.log("localDescription is Set"); signalingChannel.send(JSON.stringify({ "sdp": pc.localDescription , "idtransmitter":'"'+idpeer+'"', "idreceiver":'"'+i+'"'})); }, logError); } //Se ejecuta cuando se recibe un mensaje desde el signalingChannel (WebSocket) signalingChannel.onmessage = function (evt) { handleMessage(evt); } //Manipular el mensaje function handleMessage(evt){ var message = JSON.parse(evt.data); //Si es el ide del peer se almacena if (message.numpeer){ idpeer=message.numpeer; console.log('Peer ID: '+idpeer); return; } //Si es la lista de peer se almacena if (message.peerlist){ console.log('Peer List '+message.peerlist); peerlist=JSON.parse(message.peerlist); for (i in peerlist){ console.log("Peer: "+peerlist[i]); } return; } //guardamos el id del que envia el mensaje y el id del que debe recibirlo var id=(message.idtransmitter).split('"').join(''); var idreceiver=(message.idreceiver).split('"').join(''); console.log("Received from: "+id+" and send to: "+idreceiver); //Si es nuevo para este peer se configura la RTCPeerConection y se añade a la lista de peer. if (!pcs[id]) { console.log('%cCreate a new PeerConection','background: #222; color: #bada55'); peerlist.push(id); console.log("PEER LIST UPDATE: "+peerlist); start(false,id); } //Si el mensaje va dirigido a mi y es SDP (informacion de la sesion) if (message.sdp && idreceiver==idpeer){ //Estableco la información de la conexión remota (remoteDescription) pcs[id].setRemoteDescription(new RTCSessionDescription(message.sdp), function () { console.log("remoteDescription is Set"); // Si recibimos una oferta enviamos una respuesta. if (pcs[id].remoteDescription.type == "offer"){ console.log("Create and send ANSWER"); pcs[id].createAnswer(function(desc){localDescCreated(desc,pcs[id],id);}); } }); } //Si el mensaje va dirigido a mi y es un ICE candidate if (message.candidate && idreceiver==idpeer){ //agrego el candidato a la lista de candidatos de ese peer. console.log("Received ice candidate: "+ message.candidate.candidate); pcs[id].addIceCandidate(new RTCIceCandidate(message.candidate)); } } //Configuracion del chat function setupChat(i) { //Cuando se abra el dataChannel con ese peer se habilita el boton enviar. channel[i].onopen = function () { btnSend.disabled=false; document.getElementById("chatcontrols").style.display="inline"; }; //Cuando se reciba un mensaje por DataChannel (WebRTC) se muestra el mensaje. channel[i].onmessage = function (evt) { document.getElementById("receive").innerHTML+="<br />"+evt.data; }; } //Enviar un mensaje por DataChannel (al resto de peer) function sendChatMessage() { document.getElementById("receive").innerHTML+="<br />"+document.getElementById("login").value+ ": "+msg.value; //Para cada peer de la lista... for (i in peerlist){ if (peerlist[i]!=idpeer){ console.log("send to "+peerlist[i]); //Se envial el mensaje con el nombre de usuario y el texto a enviar. try{ channel[peerlist[i]].send(document.getElementById("login").value+ ": "+msg.value); }catch(e){ console.log(i+" said bye!"); } } } }Nota: El código completo del Peer está actualizado y disponible en Launchpad -> P2PSP > Experimentos > ChatMultiusuario > Peer.py
Una versión de este experimento está disponible para probarlo en http://www.p2psp.org/chat. Si al introducir un nickname aparece el mensaje "Conecting..." pero no aparece el cuadro de texto con el botón "send" es posible que no haya ningún otro peer (o esté detrás de un NAT simétrico), para comprobar que funciona correctamente puedes abrir otra vez la misma URL en otra pestaña de tu navegador o incluso en otro equipo de tu red.
Si tienes cualquier duda o sugerencia, usa los comentarios :-)
Este experimento, por el momento, sólo funciona en Google Chrome (incluido Chrome for Android)