- add sources.
[platform/framework/web/crosswalk.git] / src / content / test / data / media / peerconnection-call.html
1 <html>
2 <head>
3   <script type="text/javascript" src="webrtc_test_utilities.js"></script>
4   <script type="text/javascript">
5   $ = function(id) {
6     return document.getElementById(id);
7   };
8
9   var gFirstConnection = null;
10   var gSecondConnection = null;
11   var gTestWithoutMsid = false;
12
13   var gLocalStream = null;
14   var gSentTones = '';
15
16   var gRemoteStreams = {};
17
18   // Default transform functions, overridden by some test cases.
19   var transformSdp = function(sdp) { return sdp; };
20   var transformRemoteSdp = function(sdp) { return sdp; };
21   var transformCandidate = function(candidate) { return candidate; };
22
23   // When using external SDES, the crypto key is chosen by javascript.
24   var EXTERNAL_SDES_LINES = {
25     'audio': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
26         'inline:PS1uQCVeeCFCanVmcjkpPywjNWhcYD0mXXtxaVBR',
27     'video': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
28         'inline:d0RmdmcmVCspeEc3QGZiNWpVLFJhQX1cfHAwJSoj',
29     'data': 'a=crypto:1 AES_CM_128_HMAC_SHA1_80 ' +
30         'inline:NzB4d1BINUAvLEw6UzF3WSJ+PSdFcGdUJShpX1Zj'
31   };
32
33   // When using GICE, the ICE credentials can be chosen by javascript.
34   var EXTERNAL_GICE_UFRAG = '1234567890123456';
35   var EXTERNAL_GICE_PWD = '123456789012345678901234';
36
37   setAllEventsOccuredHandler(function() {
38     document.title = 'OK';
39   });
40
41   // Test that we can setup call with an audio and video track.
42   function call(constraints) {
43     createConnections(null);
44     navigator.webkitGetUserMedia(constraints,
45       addStreamToBothConnectionsAndNegotiate, printGetUserMediaError);
46     waitForVideo('remote-view-1');
47     waitForVideo('remote-view-2');
48   }
49
50   // First calls without streams on any connections, and then adds a stream
51   // to peer connection 1 which gets sent to peer connection 2. We must wait
52   // for the first negotiation to complete before starting the second one, which
53   // is why we wait until the connection is stable before re-negotiating.
54   function callEmptyThenAddOneStreamAndRenegotiate(constraints) {
55     createConnections(null);
56     negotiate();
57     waitForConnectionToStabilize(gFirstConnection);
58     navigator.webkitGetUserMedia(constraints,
59       addStreamToTheFirstConnectionAndNegotiate, printGetUserMediaError);
60     // Only the first connection is sending here.
61     waitForVideo('remote-view-2');
62   }
63
64   // First makes a call between pc1 and pc2, and then makes a call between pc3
65   // and pc4 where the remote streams from pc1 and pc2 will be used as the local
66   // streams of pc3 and pc4.
67   function callAndForwardRemoteStream(constraints) {
68     createConnections(null);
69     navigator.webkitGetUserMedia(constraints,
70                                  addStreamToBothConnectionsAndNegotiate,
71                                  printGetUserMediaError);
72     var gotRemoteStream1 = false;
73     var gotRemoteStream2 = false;
74
75     var onRemoteStream1 = function() {
76       gotRemoteStream1 = true;
77       maybeCallEstablished();
78     }
79
80     var onRemoteStream2 = function() {
81       gotRemoteStream2 = true;
82       maybeCallEstablished();
83     }
84
85     var maybeCallEstablished = function() {
86       if (gotRemoteStream1 && gotRemoteStream2) {
87         onCallEstablished();
88       }
89     }
90
91     var onCallEstablished = function() {
92       thirdConnection = createConnection(null, 'remote-view-3');
93       thirdConnection.addStream(gRemoteStreams['remote-view-1']);
94
95       fourthConnection = createConnection(null, 'remote-view-4');
96       fourthConnection.addStream(gRemoteStreams['remote-view-2']);
97
98       negotiateBetween(thirdConnection, fourthConnection);
99
100       waitForVideo('remote-view-3');
101       waitForVideo('remote-view-4');
102     }
103
104     // Do the forwarding after we have received video.
105     detectVideoIn('remote-view-1', onRemoteStream1);
106     detectVideoIn('remote-view-2', onRemoteStream2);
107   }
108
109   // Test that we can setup call with an audio and video track and
110   // simulate that the remote peer don't support MSID.
111   function callWithoutMsidAndBundle() {
112     createConnections(null);
113     transformSdp = removeBundle;
114     transformRemoteSdp = removeMsid;
115     gTestWithoutMsid = true;
116     navigator.webkitGetUserMedia({audio: true, video: true},
117       addStreamToBothConnectionsAndNegotiate, printGetUserMediaError);
118     waitForVideo('remote-view-1');
119     waitForVideo('remote-view-2');
120   }
121
122   // Test that we can setup call with legacy settings.
123   function callWithLegacySdp() {
124     transformSdp = function(sdp) {
125       return removeBundle(useGice(useExternalSdes(sdp)));
126     };
127     transformCandidate = addGiceCredsToCandidate;
128     createConnections({
129       'mandatory': {'RtpDataChannels': true, 'DtlsSrtpKeyAgreement': false}
130     });
131     setupDataChannel({reliable: false});
132     navigator.webkitGetUserMedia({audio: true, video: true},
133         addStreamToBothConnectionsAndNegotiate, printGetUserMediaError);
134     waitForVideo('remote-view-1');
135     waitForVideo('remote-view-2');
136   }
137
138   // Test only a data channel.
139   function callWithDataOnly() {
140     createConnections({optional:[{RtpDataChannels: true}]});
141     setupDataChannel({reliable: false});
142     negotiate();
143   }
144
145   function callWithSctpDataOnly() {
146     createConnections({optional: [{DtlsSrtpKeyAgreement: true}]});
147     setupSctpDataChannel({reliable: true});
148     negotiate();
149   }
150
151   // Test call with audio, video and a data channel.
152   function callWithDataAndMedia() {
153     createConnections({optional:[{RtpDataChannels: true}]});
154     setupDataChannel({reliable: false});
155     navigator.webkitGetUserMedia({audio: true, video: true},
156       addStreamToBothConnectionsAndNegotiate,
157       printGetUserMediaError);
158     waitForVideo('remote-view-1');
159     waitForVideo('remote-view-2');
160   }
161
162   function callWithSctpDataAndMedia() {
163     createConnections({optional: [{DtlsSrtpKeyAgreement: true}]});
164     setupSctpDataChannel({reliable: true});
165     navigator.webkitGetUserMedia({audio: true, video: true},
166       addStreamToBothConnectionsAndNegotiate,
167       printGetUserMediaError);
168     waitForVideo('remote-view-1');
169     waitForVideo('remote-view-2');
170   }
171
172
173   // Test call with a data channel and later add audio and video.
174   function callWithDataAndLaterAddMedia() {
175     createConnections({optional:[{RtpDataChannels: true}]});
176     setupDataChannel({reliable: false});
177     negotiate();
178
179     // Set an event handler for when the data channel has been closed.
180     setAllEventsOccuredHandler(function() {
181       // When the video is flowing the test is done.
182       setAllEventsOccuredHandler(function() {
183         document.title = 'OK';
184       });
185       navigator.webkitGetUserMedia({audio: true, video: true},
186         addStreamToBothConnectionsAndNegotiate, printGetUserMediaError);
187       waitForVideo('remote-view-1');
188       waitForVideo('remote-view-2');
189     });
190   }
191
192   // Test that we can setup call and send DTMF.
193   function callAndSendDtmf(tones) {
194     createConnections(null);
195     navigator.webkitGetUserMedia({audio: true, video: true},
196       addStreamToBothConnectionsAndNegotiate, printGetUserMediaError);
197     var onCallEstablished = function() {
198       // Send DTMF tones.
199       var localAudioTrack = gLocalStream.getAudioTracks()[0];
200       var dtmfSender = gFirstConnection.createDTMFSender(localAudioTrack);
201       dtmfSender.ontonechange = onToneChange;
202       dtmfSender.insertDTMF(tones);
203       // Wait for the DTMF tones callback.
204       document.title = 'Waiting for dtmf...';
205       addExpectedEvent();
206       var waitDtmf = setInterval(function() {
207         if (gSentTones == tones) {
208           clearInterval(waitDtmf);
209           eventOccured();
210         }
211       }, 100);
212     }
213
214     // Do the DTMF test after we have received video.
215     detectVideoIn('remote-view-2', onCallEstablished);
216   }
217
218   // Test call with a new Video MediaStream that has been created based on a
219   // stream generated by getUserMedia.
220   function callWithNewVideoMediaStream() {
221     createConnections(null);
222     navigator.webkitGetUserMedia({audio: true, video: true},
223         createNewVideoStreamAndAddToBothConnections, printGetUserMediaError);
224     waitForVideo('remote-view-1');
225     waitForVideo('remote-view-2');
226   }
227
228   // Test call with a new Video MediaStream that has been created based on a
229   // stream generated by getUserMedia. When Video is flowing, an audio track
230   // is added to the sent stream and the video track is removed. This
231   // is to test that adding and removing of remote tracks on an existing
232   // mediastream works.
233   function callWithNewVideoMediaStreamLaterSwitchToAudio() {
234     createConnections(null);
235     navigator.webkitGetUserMedia({audio: true, video: true},
236         createNewVideoStreamAndAddToBothConnections, printGetUserMediaError);
237
238     waitForVideo('remote-view-1');
239     waitForVideo('remote-view-2');
240
241     // Set an event handler for when video is playing.
242     setAllEventsOccuredHandler(function() {
243       // Add an audio track to the local stream and remove the video track and
244       // then renegotiate. But first - setup the expectations.
245       local_stream = gFirstConnection.getLocalStreams()[0];
246
247       remote_stream_1 = gFirstConnection.getRemoteStreams()[0];
248       // Add an expected event that onaddtrack will be called on the remote
249       // mediastream received on gFirstConnection when the audio track is
250       // received.
251       addExpectedEvent();
252       remote_stream_1.onaddtrack = function(){
253         expectEquals(remote_stream_1.getAudioTracks()[0].id,
254                      local_stream.getAudioTracks()[0].id);
255         eventOccured();
256       }
257
258       // Add an expectation that the received video track is removed from
259       // gFirstConnection.
260       addExpectedEvent();
261       remote_stream_1.onremovetrack = function() {
262         eventOccured();
263       }
264
265       // Add an expected event that onaddtrack will be called on the remote
266       // mediastream received on gSecondConnection when the audio track is
267       // received.
268       remote_stream_2 = gSecondConnection.getRemoteStreams()[0];
269       addExpectedEvent();
270       remote_stream_2.onaddtrack = function() {
271         expectEquals(remote_stream_2.getAudioTracks()[0].id,
272                      local_stream.getAudioTracks()[0].id);
273         eventOccured();
274       }
275
276       // Add an expectation that the received video track is removed from
277       // gSecondConnection.
278       addExpectedEvent();
279       remote_stream_2.onremovetrack = function() {
280         eventOccured();
281       }
282       // When all the above events have occurred- the test pass.
283       setAllEventsOccuredHandler(function() { document.title = 'OK'; });
284
285       local_stream.addTrack(gLocalStream.getAudioTracks()[0]);
286       local_stream.removeTrack(local_stream.getVideoTracks()[0]);
287       negotiate();
288     });  // End of setAllEventsOccuredHandler.
289   }
290
291   // This function is used for setting up a test that:
292   // 1. Creates a data channel on |gFirstConnection| and sends data to
293   //    |gSecondConnection|.
294   // 2. When data is received on |gSecondConnection| a message
295   //    is sent to |gFirstConnection|.
296   // 3. When data is received on |gFirstConnection|, the data
297   //    channel is closed. The test passes when the state transition completes.
298   function setupDataChannel(params) {
299     var sendDataString = "send some text on a data channel."
300     firstDataChannel = gFirstConnection.createDataChannel(
301         "sendDataChannel", params);
302     expectEquals('connecting', firstDataChannel.readyState);
303
304     // When |firstDataChannel| transition to open state, send a text string.
305     firstDataChannel.onopen = function() {
306       expectEquals('open', firstDataChannel.readyState);
307       firstDataChannel.send(sendDataString);
308     }
309
310     // When |firstDataChannel| receive a message, close the channel and
311     // initiate a new offer/answer exchange to complete the closure.
312     firstDataChannel.onmessage = function(event) {
313       expectEquals(event.data, sendDataString);
314       firstDataChannel.close();
315       negotiate();
316     }
317
318     // When |firstDataChannel| transition to closed state, the test pass.
319     addExpectedEvent();
320     firstDataChannel.onclose = function() {
321       expectEquals('closed', firstDataChannel.readyState);
322       eventOccured();
323     }
324
325     // Event handler for when |gSecondConnection| receive a new dataChannel.
326     gSecondConnection.ondatachannel = function (event) {
327       var secondDataChannel = event.channel;
328
329       // When |secondDataChannel| receive a message, send a message back.
330       secondDataChannel.onmessage = function(event) {
331         expectEquals(event.data, sendDataString);
332         expectEquals('open', secondDataChannel.readyState);
333         secondDataChannel.send(sendDataString);
334       }
335     }
336   }
337
338   // SCTP data channel setup is slightly different then RTP based
339   // channels. Due to a bug in libjingle, we can't send data immediately
340   // after channel becomes open. So for that reason in SCTP,
341   // we are sending data from second channel, when ondatachannel event is
342   // received. So data flow happens 2 -> 1 -> 2.
343   function setupSctpDataChannel(params) {
344     var sendDataString = "send some text on a data channel."
345     firstDataChannel = gFirstConnection.createDataChannel(
346         "sendDataChannel", params);
347     expectEquals('connecting', firstDataChannel.readyState);
348
349     // When |firstDataChannel| transition to open state, send a text string.
350     firstDataChannel.onopen = function() {
351       expectEquals('open', firstDataChannel.readyState);
352     }
353
354     // When |firstDataChannel| receive a message, send message back.
355     // initiate a new offer/answer exchange to complete the closure.
356     firstDataChannel.onmessage = function(event) {
357       expectEquals('open', firstDataChannel.readyState);
358       expectEquals(event.data, sendDataString);
359       firstDataChannel.send(sendDataString);
360     }
361
362
363     // Event handler for when |gSecondConnection| receive a new dataChannel.
364     gSecondConnection.ondatachannel = function (event) {
365       var secondDataChannel = event.channel;
366       secondDataChannel.send(sendDataString);
367
368       // When |secondDataChannel| receive a message, close the channel and
369       // initiate a new offer/answer exchange to complete the closure.
370       secondDataChannel.onmessage = function(event) {
371         expectEquals(event.data, sendDataString);
372         expectEquals('open', secondDataChannel.readyState);
373         secondDataChannel.close();
374         negotiate();
375       }
376
377       // When |secondDataChannel| transition to closed state, the test pass.
378       addExpectedEvent();
379       secondDataChannel.onclose = function() {
380         expectEquals('closed', secondDataChannel.readyState);
381         eventOccured();
382       }
383     }
384   }
385
386   // Test call with a stream that has been created by getUserMedia, clone
387   // the stream to a cloned stream, send them via the same peer connection.
388   function addTwoMediaStreamsToOneConnection() {
389     createConnections(null);
390     navigator.webkitGetUserMedia({audio: true, video: true},
391         CloneStreamAndAddTwoStreamstoOneConnection, printGetUserMediaError);
392   }
393
394   function onToneChange(tone) {
395     gSentTones += tone.tone;
396     document.title = gSentTones;
397   }
398
399   function createConnections(constraints) {
400     gFirstConnection = createConnection(constraints, 'remote-view-1');
401     expectEquals('stable', gFirstConnection.signalingState);
402
403     gSecondConnection = createConnection(constraints, 'remote-view-2');
404     expectEquals('stable', gSecondConnection.signalingState);
405   }
406
407   function createConnection(constraints, remoteView) {
408     var pc = new webkitRTCPeerConnection(null, constraints);
409     pc.onaddstream = function(event) {
410       onRemoteStream(event, remoteView);
411     }
412     return pc;
413   }
414
415   function displayAndRemember(localStream) {
416     var localStreamUrl = webkitURL.createObjectURL(localStream);
417     $('local-view').src = localStreamUrl;
418
419     gLocalStream = localStream;
420   }
421
422   // Called if getUserMedia fails.
423   function printGetUserMediaError(error) {
424     document.title = 'getUserMedia request failed with code ' + error.code;
425   }
426
427   // Called if getUserMedia succeeds and we want to send from both connections.
428   function addStreamToBothConnectionsAndNegotiate(localStream) {
429     displayAndRemember(localStream);
430     gFirstConnection.addStream(localStream);
431     gSecondConnection.addStream(localStream);
432     negotiate();
433   }
434
435   // Called if getUserMedia succeeds when we want to send from one connection.
436   function addStreamToTheFirstConnectionAndNegotiate(localStream) {
437     displayAndRemember(localStream);
438     gFirstConnection.addStream(localStream);
439     negotiate();
440   }
441
442   function verifyHasOneAudioAndVideoTrack(stream) {
443     expectEquals(1, stream.getAudioTracks().length);
444     expectEquals(1, stream.getVideoTracks().length);
445   }
446
447   // Called if getUserMedia succeeds, then clone the stream, send two streams
448   // from one peer connection.
449   function CloneStreamAndAddTwoStreamstoOneConnection(localStream) {
450     displayAndRemember(localStream);
451     var clonedStream = new webkitMediaStream();
452     clonedStream.addTrack(localStream.getVideoTracks()[0]);
453     clonedStream.addTrack(localStream.getAudioTracks()[0]);
454     gFirstConnection.addStream(localStream);
455     gFirstConnection.addStream(clonedStream);
456
457     // Verify the local streams are correct.
458     expectEquals(2, gFirstConnection.getLocalStreams().length);
459     verifyHasOneAudioAndVideoTrack(gFirstConnection.getLocalStreams()[0]);
460     verifyHasOneAudioAndVideoTrack(gFirstConnection.getLocalStreams()[1]);
461
462     // The remote side should receive two streams. After that, verify the
463     // remote side has the correct number of streams and tracks.
464     addExpectedEvent();
465     addExpectedEvent();
466     gSecondConnection.onaddstream = function(event) {
467       eventOccured();
468     }
469     setAllEventsOccuredHandler(function() {
470       // Negotiation complete, verify remote streams on the receiving side.
471       expectEquals(2, gSecondConnection.getRemoteStreams().length);
472       verifyHasOneAudioAndVideoTrack(gSecondConnection.getRemoteStreams()[0]);
473       verifyHasOneAudioAndVideoTrack(gSecondConnection.getRemoteStreams()[1]);
474
475       document.title = "OK";
476     });
477
478     negotiate();
479   }
480
481   // Called if getUserMedia succeeds when we want to send a modified
482   // MediaStream. A new MediaStream is created and the video track from
483   // |localStream| is added.
484   function createNewVideoStreamAndAddToBothConnections(localStream) {
485     displayAndRemember(localStream);
486     var new_stream = new webkitMediaStream();
487     new_stream.addTrack(localStream.getVideoTracks()[0]);
488     gFirstConnection.addStream(new_stream);
489     gSecondConnection.addStream(new_stream);
490     negotiate();
491   }
492
493   function negotiate() {
494     negotiateBetween(gFirstConnection, gSecondConnection);
495   }
496
497   function negotiateBetween(caller, callee) {
498     // Not stable = negotiation is ongoing. The behavior of re-negotiating while
499     // a negotiation is ongoing is more or less undefined, so avoid this.
500     if (caller.signalingState != 'stable')
501       throw 'You can only negotiate when the connection is stable!';
502
503     connectOnIceCandidate(caller, callee);
504
505     caller.createOffer(
506         function (offer) {
507           onOfferCreated(offer, caller, callee);
508         });
509   }
510
511   function onOfferCreated(offer, caller, callee) {
512     offer.sdp = transformSdp(offer.sdp);
513     caller.setLocalDescription(offer);
514     expectEquals('have-local-offer', caller.signalingState);
515     receiveOffer(offer.sdp, caller, callee);
516   }
517
518   function receiveOffer(offerSdp, caller, callee) {
519     offerSdp = transformRemoteSdp(offerSdp);
520
521     var parsedOffer = new RTCSessionDescription({ type: 'offer',
522                                                   sdp: offerSdp });
523     callee.setRemoteDescription(parsedOffer);
524     callee.createAnswer(function (answer) {
525                           onAnswerCreated(answer, caller, callee);
526                         });
527     expectEquals('have-remote-offer', callee.signalingState);
528   }
529
530   function removeMsid(offerSdp) {
531     offerSdp = offerSdp.replace(/a=msid-semantic.*\r\n/g, '');
532     offerSdp = offerSdp.replace('a=mid:audio\r\n', '');
533     offerSdp = offerSdp.replace('a=mid:video\r\n', '');
534     offerSdp = offerSdp.replace(/a=ssrc.*\r\n/g, '');
535     return offerSdp;
536   }
537
538   function removeBundle(sdp) {
539     return sdp.replace(/a=group:BUNDLE .*\r\n/g, '');
540   }
541
542   function useGice(sdp) {
543     sdp = sdp.replace(/t=.*\r\n/g, function(subString) {
544       return subString + 'a=ice-options:google-ice\r\n';
545     });
546     sdp = sdp.replace(/a=ice-ufrag:.*\r\n/g,
547                       'a=ice-ufrag:' + EXTERNAL_GICE_UFRAG + '\r\n');
548     sdp = sdp.replace(/a=ice-pwd:.*\r\n/g,
549                       'a=ice-pwd:' + EXTERNAL_GICE_PWD + '\r\n');
550     return sdp;
551   }
552
553   function useExternalSdes(sdp) {
554     // Remove current crypto specification.
555     sdp = sdp.replace(/a=crypto.*\r\n/g, '');
556     sdp = sdp.replace(/a=fingerprint.*\r\n/g, '');
557     // Add external crypto.  This is not compatible with |removeMsid|.
558     sdp = sdp.replace(/a=mid:(\w+)\r\n/g, function(subString, group) {
559       return subString + EXTERNAL_SDES_LINES[group] + '\r\n';
560     });
561     return sdp;
562   }
563
564   function onAnswerCreated(answer, caller, callee) {
565     answer.sdp = transformSdp(answer.sdp);
566     callee.setLocalDescription(answer);
567     expectEquals('stable', callee.signalingState);
568     receiveAnswer(answer.sdp, caller);
569   }
570
571   function receiveAnswer(answerSdp, caller) {
572     answerSdp = transformRemoteSdp(answerSdp);
573     var parsedAnswer = new RTCSessionDescription({ type: 'answer',
574                                                    sdp: answerSdp });
575     caller.setRemoteDescription(parsedAnswer);
576     expectEquals('stable', caller.signalingState);
577   }
578
579   function connectOnIceCandidate(caller, callee) {
580     caller.onicecandidate = function(event) { onIceCandidate(event, callee); }
581     callee.onicecandidate = function(event) { onIceCandidate(event, caller); }
582   }
583
584   function addGiceCredsToCandidate(candidate) {
585     return candidate.trimRight() +
586         ' username ' + EXTERNAL_GICE_UFRAG + ' password ' + EXTERNAL_GICE_PWD;
587   }
588
589   function onIceCandidate(event, target) {
590     if (event.candidate) {
591       var candidate = new RTCIceCandidate(event.candidate);
592       candidate.candidate = transformCandidate(candidate.candidate);
593       target.addIceCandidate(candidate);
594     }
595   }
596
597   function onRemoteStream(e, target) {
598     if (gTestWithoutMsid && e.stream.id != "default") {
599       document.title = 'a default remote stream was expected but instead ' +
600           e.stream.id + ' was received.';
601       return;
602     }
603     gRemoteStreams[target] = e.stream;
604     var remoteStreamUrl = webkitURL.createObjectURL(e.stream);
605     var remoteVideo = $(target);
606     remoteVideo.src = remoteStreamUrl;
607   }
608
609   </script>
610 </head>
611 <body>
612   <table border="0">
613     <tr>
614       <td>Local Preview</td>
615       <td>Remote Stream for Connection 1</td>
616       <td>Remote Stream for Connection 2</td>
617       <td>Remote Stream for Connection 3</td>
618       <td>Remote Stream for Connection 4</td>
619     </tr>
620     <tr>
621       <td><video width="320" height="240" id="local-view"
622           autoplay="autoplay"></video></td>
623       <td><video width="320" height="240" id="remote-view-1"
624           autoplay="autoplay"></video></td>
625       <td><video width="320" height="240" id="remote-view-2"
626           autoplay="autoplay"></video></td>
627       <td><video width="320" height="240" id="remote-view-3"
628           autoplay="autoplay"></video></td>
629       <td><video width="320" height="240" id="remote-view-4"
630           autoplay="autoplay"></video></td>
631       <!-- Canvases are named after their corresponding video elements. -->
632       <td><canvas width="320" height="240" id="remote-view-1-canvas"
633           style="display:none"></canvas></td>
634       <td><canvas width="320" height="240" id="remote-view-2-canvas"
635           style="display:none"></canvas></td>
636       <td><canvas width="320" height="240" id="remote-view-3-canvas"
637           style="display:none"></canvas></td>
638       <td><canvas width="320" height="240" id="remote-view-4-canvas"
639           style="display:none"></canvas></td>
640     </tr>
641   </table>
642 </body>
643 </html>