Here's something I recently presented at the DC Drupal Meetup:
You've already heard us prattle on about Hey!Watch — it's a great video transcoding service that we've gotten in the habit of using for several of our clients. As much fun as wrestling with ffmpeg is, sometimes it's more appealing to just bit then bullet, pay a dime per transcode and not have to worry about keeping your codecs up to date.
They offer some neat add-on features, too, like direct uploading of transcoded videos to your Amazon S3 account. Even more tantalizingly, there's this: a method for allowing your users to upload directly to Hey!Watch's servers, complete with fancy-pants AJAX progress indicator. There's no need to spend your server's bandwidth and CPU on videos at all — you can stick to running Drupal on your system, outsourcing all the heavy lifting. You can send along your own arbitrary variables with the video submission, then receive them back via a ping that Hey!Watch sends once transcoding is complete (allowing you to keep track of who uploaded what video). It's pretty slick.
But interested Drupal developers will click through to the Hey!Watch code and despair. First, the AJAX is written with Prototype, which won't play nicely with Drupal's preferred jQuery library. Sure, it'd be possible to rewrite it in jQuery — but I'd really rather not.
Second and more damningly (and unsurprisingly), the form that uploads to Hey!Watch needs to stand on its own. You won't be able to nest it in a Drupal form — Drupal understandably doesn't like nested forms.
A clever hacker will realize that there are a lot of ways around this problem. You could probably work something out with PageRoute, or make a popup window, or who knows what else. But we'd like to keep things as Drupaly as possible. It'd be great if it looked as if Hey!Watch was just another CCK widget.
Well, thanks to the miracle of iframes and Javascript, this is possible. Here's how:
<?php
define('VIDEO_ID',$_GET['video_id']);
define('UPLOAD_KEY',$_GET['heywatch_key']);
if(isset($_GET['success'])):
define('SUCCESS_VAL',intval($_GET['success']));
?><html>
<head>
<title>HeyWatch Video Upload - Success!</title>
<script type="text/javascript">
<!--
function ResetVideoFile()
{
if(confirm('Are you sure you want to discard \'<?php print urldecode($_GET['original_filename']);?>\' and start over?'))
{
window.parent.document.getElementById('edit-field-heywatch-video-status-0-value').value = '';
return true;
}
else
{
return false;
}
}
-->
</script>
</head>
<body>
<script type="text/javascript">
<!--
// set the parent window's video upload status field
window.parent.document.getElementById('edit-field-heywatch-video-status-0-value').value = '<?php print (SUCCESS_VAL>0) ? "success" : "error";?>';
-->
</script>
<?php if(SUCCESS_VAL==1):?>
<div id="success">Video uploaded successfully. There will be a delay while we convert it to a usable format.</div>
<?php elseif(SUCCESS_VAL==2): ?>
<div id="exists">
<p><strong>The video file <em><?php print urldecode($_GET['original_filename']);?></em> has been successfully uploaded.</strong></p>
<p>To erase this file and upload a new one, click <a href="http://<?php print $_SERVER['SERVER_NAME'] . $_SERVER['PHP_SELF'];?>?video_id=<?php print VIDEO_ID;?>&heywatch_key=<?php print UPLOAD_KEY;?>" onclick="return ResetVideoFile()">here</a>.</p>
</div>
<?php else:?>
<div id="error">There was a problem uploading your video. Please click <a href="http://<?php print $_SERVER['SERVER_NAME'] . $_SERVER['PHP_SELF'];?>?video_id=<?php print VIDEO_ID;?>&video_key=<?php print UPLOAD_KEY;?>">here</a> to try again.</div>
<?php endif;?>
</body>
</html><?php
else:
?><html>
<head>
<title>HeyWatch Video Upload</title>
<script type="text/javascript" src="prototype.js"></script>
<script type="text/javascript">
<!--
/* don't forget to change your upload_key */
var UploadProgress = {
uploading: null,
monitor: function(upid) {
if(!this.periodicExecuter) {
this.periodicExecuter = new PeriodicalExecuter(function() {
if(!UploadProgress.uploading) return;
new Ajax.Request('ajaxproxy.php?path=/upload/' + upid+'?key=<?php print UPLOAD_KEY;?>', {method:'get'});
}, 3);
}
this.uploading = true;
this.StatusBar.create();
},
/* change this function if you want to change the progression */
update: function(total, current) {
if(!this.uploading) return;
var status = current / total;
var statusHTML = status.toPercentage();
$('results').innerHTML = "<span>" + statusHTML + "</span> - " + current.toHumanSize() + ' of ' + total.toHumanSize() + " uploaded.";
this.StatusBar.update(status, statusHTML);
},
finish: function(video_id) {
this.uploading = false;
this.StatusBar.finish();
$('results').innerHTML = "finished";
$('progress-bar').hide();
},
error: function(msg) {
this.uploading = false;
this.StatusBar.finish();
$('results').innerHTML = "<img src='/images/log_error.gif' alt='error' /> Error with the file: "+msg+".";
$('progress-bar').hide();
setTimeout("window.location = '/transfer/new'", 3000);
},
cancel: function(msg) {
if(!this.uploading) return;
this.uploading = false;
if(this.StatusBar.statusText) this.StatusBar.statusText.innerHTML = msg || 'canceled';
},
StatusBar: {
statusBar: null,
statusText: null,
statusBarWidth: 500,
create: function() {
this.statusBar = this._createStatus('status-bar');
this.statusBar.style.width = '0';
},
update: function(status, statusHTML) {
this.statusBar.style.width = status*100 + '%';
},
finish: function() {
this.statusBar.style.width = '100%';
},
_createStatus: function(id) {
el = $(id);
if(!el) {
el = document.createElement('span');
el.setAttribute('id', id);
$('progress-bar').appendChild(el);
}
return el;
}
},
FileField: {
add: function() {
new Insertion.Bottom('file-fields', '<p style="display:none"><input id="data" name="data" type="file" /> <a href="#" onclick="UploadProgress.FileField.remove(this);return false;">x</a></p>')
$$('#file-fields p').last().visualEffect('blind_down', {duration:0.3});
},
remove: function(anchor) {
anchor.parentNode.visualEffect('drop_out', {duration:0.25});
}
}
}
Number.prototype.bytes = function() { return this; };
Number.prototype.kilobytes = function() { return this * 1024; };
Number.prototype.megabytes = function() { return this * (1024).kilobytes(); };
Number.prototype.gigabytes = function() { return this * (1024).megabytes(); };
Number.prototype.terabytes = function() { return this * (1024).gigabytes(); };
Number.prototype.petabytes = function() { return this * (1024).terabytes(); };
Number.prototype.exabytes = function() { return this * (1024).petabytes(); };
['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte', 'petabyte', 'exabyte'].each(function(meth) {
Number.prototype[meth] = Number.prototype[meth+'s'];
});
Number.prototype.toPrecision = function() {
var precision = arguments[0] || 2;
var s = Math.round(this * Math.pow(10, precision)).toString();
var pos = s.length - precision;
var last = s.substr(pos, precision);
return s.substr(0, pos) + (last.match("^0{" + precision + "}$") ? '' : '.' + last);
}
// (1/10).toPercentage()
// # => '10%'
Number.prototype.toPercentage = function() {
return (this * 100).toPrecision() + '%';
}
Number.prototype.toHumanSize = function() {
if(this < (1).kilobyte()) return this + " Bytes";
if(this < (1).megabyte()) return (this / (1).kilobyte()).toPrecision() + ' KB';
if(this < (1).gigabytes()) return (this / (1).megabyte()).toPrecision() + ' MB';
if(this < (1).terabytes()) return (this / (1).gigabytes()).toPrecision() + ' GB';
if(this < (1).petabytes()) return (this / (1).terabytes()).toPrecision() + ' TB';
if(this < (1).exabytes()) return (this / (1).petabytes()).toPrecision() + ' PB';
return (this / (1).exabytes()).toPrecision() + ' EB';
}
-->
</script>
<?php endif;?>
</head>
<body>
<p id="instructions">Please choose a file and click the "Upload" button.</p>
<!-- javascript enabled AJAX upload -->
<div id="form-stuff">
<form action="http://heywatch.com/upload?upload_id=<?php print VIDEO_ID;?>" enctype="multipart/form-data" id="form-upload-tag" method="post" onsubmit="window.parent.document.getElementById('edit-field-heywatch-video-original-f-0-value').value = $('data').value; $('form-stuff').toggle(); $('progress-bar').toggle(); UploadProgress.monitor('<?php print VIDEO_ID;?>');">
<p><input id="data" name="data" type="file" /></p>
<input type="hidden" value="<?php print UPLOAD_KEY;?>" name="key" />
<input type="hidden" value="http://<?php print $_SERVER['SERVER_NAME'] . $_SERVER['PHP_SELF'];?>?success=1&video_id=<?php print VIDEO_ID;?>&key=<?php print UPLOAD_KEY;?>" name="redirect_if_success" />
<input type="hidden" value="http://<?php print $_SERVER['SERVER_NAME'] . $_SERVER['PHP_SELF'];?>?success=0&video_id=<?php print VIDEO_ID;?>&key=<?php print UPLOAD_KEY;?>" name="redirect_if_error" />
<input type="hidden" value="<?php print VIDEO_ID;?>" name="title" />
<input type="hidden" value="<?php print VIDEO_ID;?>" name="video_id" />
<p><input name="commit" id="submit" type="submit" value="Upload" /></p>
</form>
</div>
<div id="progress-bar" style="display:none"></div>
<div id="results"></div>
</body>
</html>
<?php
endif;
?>
if($form_id=='heywatchccktype_node_form')
{
$heywatch_upload_key = variable_get('heywatch_upload_key','0');
// retrieve the video_id, or generate a new one if necessary
$video_id = -1;
if(strlen(trim($form['#post']['field_heywatch_video_status'][0]['value']))>0)
{
$video_id = $form['#post']['field_heywatch_video_id'][0]['value'];
} elseif(strlen(trim($form['group_heywatch']['field_heywatch_video_status'][0]['value']['#default_value']))>0)
{
$video_id = $form['group_heywatch']['field_heywatch_video_id'][0]['value']['#default_value'];
}
else
{
$video_id = db_next_id('heywatch_video_id'); $form['group_heywatch']['field_heywatch_video_id'][0]['value']['#default_value'] = $video_id;
}
// set the iframe up
$iframe_src = '/path/to/your/heywatch/AJAX/script.php?video_id=' . $video_id . '&heywatch_key=' . $heywatch_upload_key;
if(trim($form['#post']['field_heywatch_video_status'][0]['value'])=='success')
$iframe_src .= "&success=1";
else if($form['group_heywatch']['field_heywatch_video_status'][0]['value']['#default_value']=='success')
$iframe_src .= "&success=2&original_filename=" . urlencode(basename(str_replace("\\","/",$form['group_heywatch']['field_heywatch_video_original_f'][0]['value']['#default_value'])));
$form['heywatch_iframe_container'] = array
(
'#type' => 'fieldset',
'#title' => t('Video File'),
'#collapsible' => FALSE,
'#collapsed' => FALSE,
);
$form['heywatch_iframe_container']['heywatch_iframe'] = array
(
'#type' => 'markup',
'#weight' => 9,
'#value' => '<iframe src="' . $iframe_src . '" id="heywatch"></iframe>',
);
}
fieldset.group-heywatch {
display: none;
}
iframe#heywatch {
width: 100%;
border: 0;
overflow: visible;
}
You may want to specify a height value for the iframe, too. It'll likely depend on how you decided to customize the AJAX uploader and your success or failure status displays.So how the heck does this all work? Well, in a nutshell:
There are a few things I've left out. First, Hey!Watch is quite fast — fast enough that a slow-typing user may find their video transcoded (and the Hey!Watch ping received) before they've saved the node they're trying to attach their video to. There's also the question of how the ping-handling code should find the node with just a video ID to go on — laboriously cycling through nodes is a bad, bad idea.
The answer is a video_id » nid lookup table, with an extra field to temporarily hold incoming pings' filename values for nodes that haven't yet been saved. While you're at it you should probably also add a uid column to keep users from stealing one another's video IDs. You'll then want to change that db_next_id() in hook_form_alter() to a routine that calls db_next_id() and inserts the returned value and the user's uid into that lookup table. You'll also want to add code to hook_nodeapi's insert handler to check to see whether an early-arriving ping has queued up information that ought to be associated with the newly-available nid.
All of this gets a little complicated, I'll admit, so I've omitted it — I really didn't want this post to have to get bogged down at the SQL level when it's really intended as an idea starter. Besides, there are still other questions you'll have to answer about how to keep users from borrowing your Hey!Watch upload key or overwriting existing videos. But that's a bit much for this conversation — I'm already worried that the code listed above has some errors in it, as I've just done a quick pass at scraping project-specific information out of it. But as complicated as it may seem, I still think this setup is vastly preferable to maintaining your own transcoding service.
And hopefully this demonstrates a general technique for getting around some limitations imposed by CCK. Iframes, hidden fields and Javascript can let you create interactions between Drupal and entirely distinct web development worlds, and do so in a way that's transparent to your users.
Post new comment