From 674b2e8f1a8b3659d91e2e6e5a337e3134d757a3 Mon Sep 17 00:00:00 2001 From: ccd0 Date: Sat, 9 Nov 2013 01:11:22 -0800 Subject: [PATCH] add files --- LICENSE.md | 22 ++ README.md | 29 ++- collapse.gif | Bin 0 -> 67 bytes expandvideo.js | 118 ++++++++++ matroska-elements.txt | 224 +++++++++++++++++++ matroska.php | 487 ++++++++++++++++++++++++++++++++++++++++++ player.php | 14 ++ playersettings.js | 7 + post_reply.html | 125 +++++++++++ post_thread.html | 174 +++++++++++++++ posthandler.php | 46 ++++ settings.js | 46 ++++ videodata.php | 131 ++++++++++++ 13 files changed, 1421 insertions(+), 2 deletions(-) create mode 100644 LICENSE.md create mode 100644 collapse.gif create mode 100644 expandvideo.js create mode 100644 matroska-elements.txt create mode 100644 matroska.php create mode 100644 player.php create mode 100644 playersettings.js create mode 100644 post_reply.html create mode 100644 post_thread.html create mode 100644 posthandler.php create mode 100644 settings.js create mode 100644 videodata.php diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..74cf71ee --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +# License +Copyright (c) 2010-2013 Tinyboard Development Group (tinyboard.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +All copyright notices and permission notices (including this file) shall be +included and remain unedited in all copies or substantial portions of the +Software. This explicitly includes but is not limited to the Tinyboard copyright +notices found in the footers of some template files. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 12c62c9f..ec139b81 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,27 @@ -containerchan -============= +This project is an effort to enable imageboards to host small video clips. With modern video compression, it's possible to share much higher-quality videos in a few megabytes than the with animated GIF files. + +The software here extends [Tinyboard](http://tinyboard.org/) to display metadata and create pseudo-thumbnails for WebM video files. It is intended to work on very basic web hosting services, including any hosting service that can run Tinyboard. In particular, it does not depend on any video conversion software such as FFmpeg. For this reason, it cannot create true thumbnails, but uses pseudo-thumbnails consisting of a single frame extracted from the video. + +A board using this code can be found at: +http://containerchan.org/tb/demo/ + +Be aware that this is beta software. Please report any bugs you find. + +The modified Tinyboard templates (post_reply.html and post_thread.html) are subject to the Tinyboard licence (see LICENSE.md). The portions of this software not derived from Tinyboard are released into the public domain. + + +INSTALLATION + +Create a directory named cc at the root of your Tinyboard installation. Upload these files into that directory. + +Replace the files templates/post_thread.html and templates/post_reply.html with the files given here. + +Add these lines to inc/instance-config.php: +$config['allowed_ext_files'][] = 'webm'; +$config['additional_javascript'][] = 'cc/settings.js'; +$config['additional_javascript'][] = 'cc/expandvideo.js'; +require_once 'cc/posthandler.php'; +event_handler('post', 'postHandler'); + +And add this to stylesheets/style.css: +video.post-image {display: block; float: left; margin: 10px 20px; border: none;} diff --git a/collapse.gif b/collapse.gif new file mode 100644 index 0000000000000000000000000000000000000000..cda6b337dfe24ce0a12e2637bca9f83248917d9e GIT binary patch literal 67 zcmZ?wbhEHbdatatype = $fields[1]; + $t->name = $fields[2]; + $t->validParents = array(); + for ($i = 0; $i + 3 < count($fields); $i++) { + if ($fields[$i+3] == '*' || $fields[$i+3] == 'root') { + $t->validParents[$i] = $fields[$i+3]; + } else { + $t->validParents[$i] = hexdec($fields[$i+3]); + } + } + $this->_els[$id] = $t; + } + } + + public function exists($id) { + return isset($this->_els[$id]); + } + + public function name($id) { + if (!isset($this->_els[$id])) return NULL; + return $this->_els[$id]->name; + } + + public function datatype($id) { + if ($id == 'root') return 'container'; + if (!isset($this->_els[$id])) return 'binary'; + return $this->_els[$id]->datatype; + } + + public function validChild($id1, $id2) { + if (!isset($this->_els[$id2])) return TRUE; + $parents = $this->_els[$id2]->validParents; + return in_array('*', $parents) || in_array($id1, $parents); + } +} + +// Decode big-endian integer +function ebmlDecodeInt($data, $signed=FALSE, $carryIn=0) { + $n = $carryIn; + if (strlen($data) > 8) throw new Exception('not supported: integer too long'); + for ($i = 0; $i < strlen($data); $i++) { + if ($n > (PHP_INT_MAX >> 8) || $n < ((-PHP_INT_MAX-1) >> 8)) { + $n = floatval($n); + } + $n = $n * 0x100 + ord($data[$i]); + if ($i == 0 && $signed && ($n & 0x80) != 0) { + $n -= 0x100; + } + } + return $n; +} + +// Decode big-endian IEEE float +function ebmlDecodeFloat($data) { + switch (strlen($data)) { + case 0: + return 0; + case 4: + switch(pack('f', 1e9)) { + case '(knN': + $arr = unpack('f', strrev($data)); + return $arr[1]; + case 'Nnk(': + $arr = unpack('f', $data); + return $arr[1]; + default: + error_log('cannot decode floats'); + return NULL; + } + case 8: + switch(pack('d', 1e9)) { + case "\x00\x00\x00\x00\x65\xcd\xcd\x41": + $arr = unpack('d', strrev($data)); + return $arr[1]; + case "\x41\xcd\xcd\x65\x00\x00\x00\x00": + $arr = unpack('d', $data); + return $arr[1]; + default: + error_log('cannot decode floats'); + return NULL; + } + default: + error_log('unsupported float length'); + return NULL; + } +} + +// Decode big-endian signed offset from Jan 01, 2000 in nanoseconds +// Convert to offset from Jan 01, 1970 in seconds +function ebmlDecodeDate($data) { + return ebmlDecodeInt($data, TRUE) * 1e-9 + 946684800; +} + +// Decode data of specified datatype +function ebmlDecode($data, $datatype) { + switch ($datatype) { + case 'int': return ebmlDecodeInt($data, TRUE); + case 'uint': return ebmlDecodeInt($data, FALSE); + case 'float': return ebmlDecodeFloat($data); + case 'string': return chop($data, "\0"); + case 'date': return ebmlDecodeDate($data); + case 'binary': return $data; + default: throw new Exception('unknown datatype'); + } +} + +// Methods for reading data from section of EBML file +class EBMLReader { + private $_fileHandle; + private $_offset; + private $_size; + private $_position; + + public function __construct($fileHandle, $offset=0, $size=NULL) { + $this->_fileHandle = $fileHandle; + $this->_offset = $offset; + $this->_size = $size; + $this->_position = 0; + } + + // Tell position within data section + public function position() { + return $this->_position; + } + + // Set position within data section + public function setPosition($position) { + $this->_position = $position; + } + + // Total size of data section (NULL if unknown) + public function size() { + return $this->_size; + } + + // Set end of data section + public function setSize($size) { + if ($this->_size === NULL) { + $this->_size = $size; + } else { + throw new Exception('size already set'); + } + } + + // Determine whether we are at end of data + public function endOfData() { + if ($this->_size === NULL) { + fseek($this->_fileHandle, $this->_offset + $this->_position); + fread($this->_fileHandle, 1); + if (feof($this->_fileHandle)) { + $this->_size = $this->_position; + return TRUE; + } else { + return FALSE; + } + } else { + return $this->_position >= $this->_size; + } + } + + // Create EBMLReader containing $size bytes and advance + public function nextSlice($size) { + $slice = new EBMLReader($this->_fileHandle, $this->_offset + $this->_position, $size); + if ($size !== NULL) { + $this->_position += $size; + if ($this->_size !== NULL && $this->_position > $this->_size) { + throw new Exception('unexpected end of data'); + } + } + return $slice; + } + + // Read entire region + public function readAll() { + if ($this->_size == 0) return ''; + if ($this->_size === NULL) throw new Exception('unknown length'); + fseek($this->_fileHandle, $this->_offset); + $data = fread($this->_fileHandle, $this->_size); + if ($data === FALSE || strlen($data) != $this->_size) { + throw new Exception('error reading from file'); + } + return $data; + } + + // Read $size bytes + public function read($size) { + return $this->nextSlice($size)->readAll(); + } + + // Read variable-length integer + public function readVarInt($signed=FALSE) { + // Read size and remove flag + $n = ord($this->read(1)); + $size = 0; + if ($n == 0) { + throw new Exception('not supported: variable-length integer too long'); + } + $flag = 0x80; + while (($n & $flag) == 0) { + $flag = $flag >> 1; + $size++; + } + $n -= $flag; + + // Read remaining data + $rawInt = $this->read($size); + + // Check for all ones + if ($n == $flag - 1 && $rawInt == str_repeat("\xFF", $size)) { + return NULL; + } + + // Range shift for signed integers + if ($signed) { + if ($flag == 0x01) { + $n = ord($rawInt[0]) - 0x80; + $rawInt = $rawInt.substr(1); + } else { + $n -= ($flag >> 1); + } + } + + // Convert to integer + $n = ebmlDecodeInt($rawInt, FALSE, $n); + + // Range shift for signed integers + if ($signed) { + if ($n == PHP_INT_MAX) { + $n = floatval($n); + } + $n++; + } + + return $n; + } +} + +// EBML element +class EBMLElement { + private $_id; + private $_name; + private $_datatype; + private $_content; + private $_headSize; + protected $_elementTypeList; + + public function __construct($id, $content, $headSize, $elementTypeList) { + $this->_id = $id; + $this->_name = $elementTypeList->name($this->_id); + $this->_datatype = $elementTypeList->datatype($this->_id); + $this->_content = $content; + $this->_headSize = $headSize; + $this->_elementTypeList = $elementTypeList; + } + + public function id() {return $this->_id;} + public function name() {return $this->_name;} + public function datatype() {return $this->_datatype;} + public function content() {return $this->_content;} + public function headSize() {return $this->_headSize;} + + // Total size of element (including ID and datasize) + public function size() { + return $this->_headSize + $this->_content->size(); + } + + // Read and interpret content + public function value() { + if ($this->_datatype == 'binary') { + return $this->_content; + } else { + return ebmlDecode($this->_content->readAll(), $this->_datatype); + } + } +} + +// Iterate over EBML elements in data +class EBMLElementList extends EBMLElement implements Iterator { + private $_cache; + private $_position; + private static $MAX_ELEMENTS = 10000; + + public function __construct($id, $content, $headSize, $elementTypeList) { + parent::__construct($id, $content, $headSize, $elementTypeList); + $this->_cache = array(); + $this->_position = 0; + } + + public function rewind() { + $this->_position = 0; + } + + public function current() { + if ($this->valid()) { + return $this->_cache[$this->_position]; + } else { + return NULL; + } + } + + public function key() { + return $this->_position; + } + + public function next() { + $this->_position += $this->current()->size(); + if ($this->content()->size() !== NULL && $this->_position > $this->content()->size()) { + throw new Exception('unexpected end of data'); + } + } + + public function valid() { + if (isset($this->_cache[$this->_position])) return TRUE; + $this->content()->setPosition($this->_position); + if ($this->content()->endOfData()) return FALSE; + $id = $this->content()->readVarInt(); + if ($id === NULL) throw new Exception('invalid ID'); + if ($this->content()->size() === NULL && !$this->_elementTypeList->validChild($this->id(), $id)) { + $this->content()->setSize($this->_position); + return FALSE; + } + $size = $this->content()->readVarInt(); + $headSize = $this->content()->position() - $this->_position; + $content = $this->content()->nextSlice($size); + if ($this->_elementTypeList->datatype($id) == 'container') { + $element = new EBMLElementList($id, $content, $headSize, $this->_elementTypeList); + } else { + if ($size === NULL) { + throw new Exception('non-container element of unknown size'); + } + $element = new EBMLElement($id, $content, $headSize, $this->_elementTypeList); + } + $this->_cache[$this->_position] = $element; + return TRUE; + } + + // Total size of element (including ID and size) + public function size() { + if ($this->content()->size() === NULL) { + $iElement = 0; + foreach ($this as $element) { // iterate over elements to find end + $iElement++; + if ($iElement > self::$MAX_ELEMENTS) throw new Exception('not supported: too many elements'); + } + } + return $this->headSize() + $this->content()->size(); + } + + // Read and interpret content + public function value() { + return $this; + } + + // Get element value by name + public function get($name, $defaultValue=NULL) { + $iElement = 0; + foreach ($this as $element) { + $iElement++; + if ($iElement > self::$MAX_ELEMENTS) throw new Exception('not supported: too many elements'); + if ($element->name() == $name) { + return $element->value(); + } + } + return $defaultValue; + } +} + +// Parse block +class MatroskaBlock { + const LACING_NONE = 0; + const LACING_XIPH = 1; + const LACING_EBML = 3; + const LACING_FIXED = 2; + public $trackNumber; + public $timecode; + public $keyframe; + public $invisible; + public $lacing; + public $discardable; + public $frames; + + public function __construct($reader) { + # Header + $this->trackNumber = $reader->readVarInt(); + $this->timecode = ebmlDecodeInt($reader->read(2), TRUE); + $flags = ord($reader->read(1)); + if (($flags & 0x70) != 0) { + throw new Exception('reserved flags set'); + } + $this->keyframe = (($flags & 0x80) != 0); + $this->invisible = (($flags & 0x08) != 0); + $this->lacing = ($flags >> 1) & 0x03; + $this->discardable = (($flags & 0x01) != 0); + + # Lacing sizes + if ($this->lacing == self::LACING_NONE) { + $nsizes = 0; + } else { + $nsizes = ord($reader->read(1)); + } + $sizes = array(); + switch ($this->lacing) { + case self::LACING_XIPH: + for ($i = 0; $i < $nsizes; $i++) { + $size = 0; + $x = 255; + while ($x == 255) { + $x = ord($reader->read(1)); + $size += $x; + if ($size > 65536) throw new Exception('not supported: laced frame too long'); + } + $sizes[$i] = $size; + } + break; + case self::LACING_EBML: + $size = 0; + for ($i = 0; $i < $nsizes; $i++) { + $dsize = $reader->readVarInt($i != 0); + if ($dsize === NULL || $size + $dsize < 0) { + throw new Exception('invalid frame size'); + } + $size += $dsize; + $sizes[$i] = $size; + } + break; + case self::LACING_FIXED: + $lenRemaining = $reader->size() - $reader->position(); + if ($lenRemaining % ($nsizes + 1) != 0) { + throw new Exception('data size not divisible by frame count'); + } + $size = (int) ($lenRemaining / ($nsizes + 1)); + for ($i = 0; $i < $nsizes; $i++) { + $sizes[$i] = $size; + } + break; + } + + # Frames + $this->frames = array(); + for ($i = 0; $i < $nsizes; $i++) { + $this->frames[$i] = $reader->nextSlice($sizes[$i]); + } + $this->frames[$nsizes] = $reader->nextSlice($reader->size() - $reader->position()); + } +} + +// Create element list from $fileHandle +function readMatroska($fileHandle) { + $reader = new EBMLReader($fileHandle); + if ($reader->read(4) != "\x1a\x45\xdf\xa3") { + throw new Exception('not an EBML file'); + } + $matroskaElementTypeList = new EBMLElementTypeList(dirname(__FILE__) . '/matroska-elements.txt'); + $root = new EBMLElementList('root', $reader, 0, $matroskaElementTypeList); + $header = $root->get('EBML'); + $ebmlVersion = $header->get('EBMLReadVersion', 1); + $docType = $header->get('DocType'); + $docTypeVersion = $header->get('DocTypeReadVersion', 1); + if ($ebmlVersion != 1) { + throw new Exception('unsupported EBML version'); + } + if ($docType != 'matroska' && $docType != 'webm') { + throw new Exception ('unsupported document type'); + } + if ($docTypeVersion < 1 || $docTypeVersion > 4) { + throw new Exception ('unsupported document type version'); + } + return $root; +} diff --git a/player.php b/player.php new file mode 100644 index 00000000..ecfaf953 --- /dev/null +++ b/player.php @@ -0,0 +1,14 @@ + + + + + <?php echo htmlspecialchars($_GET["t"]) ?> + + + + + + + diff --git a/playersettings.js b/playersettings.js new file mode 100644 index 00000000..6e503070 --- /dev/null +++ b/playersettings.js @@ -0,0 +1,7 @@ +window.onload = function() { + settingsPanel.style.cssFloat = "right"; + document.body.insertBefore(settingsPanel, document.body.firstChild); + var video = document.getElementsByTagName("video")[0]; + video.muted = setting("videomuted"); + video.play(); +}; diff --git a/post_reply.html b/post_reply.html new file mode 100644 index 00000000..aaadeefd --- /dev/null +++ b/post_reply.html @@ -0,0 +1,125 @@ +{% filter remove_whitespace %} +{# tabs and new lines will be ignored #} +
+ +

+ + + {% if config.poster_ids %} + ID: {{ post.ip|poster_id(post.thread) }} + {% endif %} + No. + + {{ post.id }} + +

+ {% if post.embed %} + {{ post.embed }} + {% elseif post.file == 'deleted' %} + + {% elseif post.file and post.file %} +

File: {{ post.file }} + ( + {% if post.thumb == 'spoiler' %} + Spoiler Image, + {% endif %} + {{ post.filesize|filesize }} + {% if post.filewidth and post.fileheight %} + , {{ post.filewidth}}x{{ post.fileheight }} + {% if config.show_ratio %} + , {{ post.ratio }} + {% endif %} + {% endif %} + {% if config.show_filename and post.filename %} + , + {% if post.filename|length > config.max_filename_display %} + {{ post.filename|truncate(config.max_filename_display)|bidi_cleanup }} + {% else %} + {{ post.filename|e|bidi_cleanup }} + {% endif %} + {% endif %} + {% if post.thumb != 'file' and config.image_identification %} + , + + io + {% if post.file|extension == 'jpg' %} + e + {% endif %} + g + t + + {% endif %} + + ) + +

+ + <{% if post.thumb|extension == 'webm' %}video preload{% else %}img{% endif %} class="post-image" src=" + {% if post.thumb == 'file' %} + {{ config.root }} + {% if config.file_icons[post.filename|extension] %} + {{ config.file_thumb|sprintf(config.file_icons[post.filename|extension]) }} + {% else %} + {{ config.file_thumb|sprintf(config.file_icons.default) }} + {% endif %} + {% elseif post.thumb == 'spoiler' %} + {{ config.root }}{{ config.spoiler_image }} + {% else %} + {{ config.uri_thumb }}{{ post.thumb }} + {% endif %}" style="width:{{ post.thumbwidth }}px;height:{{ post.thumbheight }}px" + {% if post.thumb|extension == 'webm' %}>{% else %}alt="" />{% endif %} + + {% endif %} + {{ post.postControls }} +
+ {% endfilter %}{% if index %}{{ post.body|truncate_body(post.link) }}{% else %}{{ post.body }}{% endif %}{% filter remove_whitespace %} + {% if post.modifiers['ban message'] %} + {{ config.mod.ban_message|sprintf(post.modifiers['ban message']) }} + {% endif %} +
+
+
+{% endfilter %} diff --git a/post_thread.html b/post_thread.html new file mode 100644 index 00000000..1e9c0f27 --- /dev/null +++ b/post_thread.html @@ -0,0 +1,174 @@ +{% filter remove_whitespace %} +{# tabs and new lines will be ignored #} + +
+ +{% if post.embed %} + {{ post.embed }} +{% elseif post.file == 'deleted' %} + +{% elseif post.file and post.file %} +

{% trans %}File:{% endtrans %} {{ post.file }} + ( + {% if post.thumb == 'spoiler' %} + {% trans %}Spoiler Image{% endtrans %}, + {% endif %} + {{ post.filesize|filesize }} + {% if post.filewidth and post.fileheight %} + , {{ post.filewidth}}x{{ post.fileheight }} + {% if config.show_ratio %} + , {{ post.ratio }} + {% endif %} + {% endif %} + {% if config.show_filename and post.filename %} + , + {% if post.filename|length > config.max_filename_display %} + {{ post.filename|truncate(config.max_filename_display)|bidi_cleanup }} + {% else %} + {{ post.filename|e|bidi_cleanup }} + {% endif %} + {% endif %} + {% if post.thumb != 'file' and config.image_identification %} + , + + io + {% if post.file|extension == 'jpg' %} + e + {% endif %} + g + t + + {% endif %} + ) +

+ +<{% if post.thumb|extension == 'webm' %}video preload{% else %}img{% endif %} class="post-image" src=" + {% if post.thumb == 'file' %} + {{ config.root }} + {% if config.file_icons[post.filename|extension] %} + {{ config.file_thumb|sprintf(config.file_icons[post.filename|extension]) }} + {% else %} + {{ config.file_thumb|sprintf(config.file_icons.default) }} + {% endif %} + {% elseif post.thumb == 'spoiler' %} + {{ config.root }}{{ config.spoiler_image }} + {% else %} + {{ config.uri_thumb }}{{ post.thumb }} + {% endif %}" style="width:{{ post.thumbwidth }}px;height:{{ post.thumbheight }}px" + {% if post.thumb|extension == 'webm' %}>{% else %}alt="" />{% endif %} +{% endif %} +

+ + + {% if config.poster_ids %} + ID: {{ post.ip|poster_id(post.id) }} + {% endif %} + No. + + {{ post.id }} + + {% if post.sticky %} + {% if config.font_awesome %} + + {% else %} + Sticky + {% endif %} + {% endif %} + {% if post.locked %} + {% if config.font_awesome %} + + {% else %} + Locked + {% endif %} + {% endif %} + {% if post.bumplocked and (config.mod.view_bumplock < 0 or (post.mod and post.mod|hasPermission(config.mod.view_bumplock, board.uri))) %} + {% if config.font_awesome %} + + {% else %} + Bumplocked + {% endif %} + {% endif %} + {% if index %} + [{% trans %}Reply{% endtrans %}] + {% endif %} + {{ post.postControls }} +

+
+ {% endfilter %}{% if index %}{{ post.body|truncate_body(post.link) }}{% else %}{{ post.body }}{% endif %}{% filter remove_whitespace %} + {% if post.modifiers['ban message'] %} + {{ config.mod.ban_message|sprintf(post.modifiers['ban message']) }} + {% endif %} +
+ {% if post.omitted or post.omitted_images %} + + {% if post.omitted %} + {% trans %} + 1 post + {% plural post.omitted %} + {{ count }} posts + {% endtrans %} + {% if post.omitted_images %} + {% trans %}and{% endtrans %} + {% endif %} + {% endif %} + {% if post.omitted_images %} + {% trans %} + 1 image reply + {% plural post.omitted_images %} + {{ count }} image replies + {% endtrans %} + {% endif %} {% trans %}omitted. Click reply to view.{% endtrans %} + + {% endif %} +{% if not index %} +{% endif %} +
{% endfilter %} +{% set hr = post.hr %} +{% for post in post.posts %} + {% include 'post_reply.html' %} +{% endfor %} +
{% if hr %}
{% endif %} +
diff --git a/posthandler.php b/posthandler.php new file mode 100644 index 00000000..f2f60291 --- /dev/null +++ b/posthandler.php @@ -0,0 +1,46 @@ +has_file && $post->extension == 'webm') { + require_once dirname(__FILE__) . '/videodata.php'; + $videoDetails = videoData($post->file_path); + + // Set thumbnail + $thumbName = $board['dir'] . $config['dir']['thumb'] . $post->file_id . '.webm'; + if ($config['spoiler_images'] && isset($_POST['spoiler'])) { + // Use spoiler thumbnail + $post->thumb = 'spoiler'; + $size = @getimagesize($config['spoiler_image']); + $post->thumbwidth = $size[0]; + $post->thumbheight = $size[1]; + } elseif (isset($videoDetails['frame']) && $thumbFile = fopen($thumbName, 'wb')) { + // Use single frame from video as pseudo-thumbnail + fwrite($thumbFile, $videoDetails['frame']); + fclose($thumbFile); + $post->thumb = $post->file_id . '.webm'; + } else { + // Fall back to file thumbnail + $post->thumb = 'file'; + } + unset($videoDetails['frame']); + + // Set width and height + if (isset($videoDetails['width']) && isset($videoDetails['height'])) { + $post->width = $videoDetails['width']; + $post->height = $videoDetails['height']; + if ($post->thumb != 'file' && $post->thumb != 'spoiler') { + $thumbMaxWidth = $post->op ? $config['thumb_op_width'] : $config['thumb_width']; + $thumbMaxHeight = $post->op ? $config['thumb_op_height'] : $config['thumb_height']; + if ($videoDetails['width'] > $thumbMaxWidth || $videoDetails['height'] > $thumbMaxHeight) { + $post->thumbwidth = min($thumbMaxWidth, intval(round($videoDetails['width'] * $thumbMaxHeight / $videoDetails['height']))); + $post->thumbheight = min($thumbMaxHeight, intval(round($videoDetails['height'] * $thumbMaxWidth / $videoDetails['width']))); + } else { + $post->thumbwidth = $videoDetails['width']; + $post->thumbheight = $videoDetails['height']; + } + } + } + } +} diff --git a/settings.js b/settings.js new file mode 100644 index 00000000..8dc9950c --- /dev/null +++ b/settings.js @@ -0,0 +1,46 @@ +var settingsPanel = document.createElement("div"); +settingsPanel.innerHTML = '
Settings
' + + '
' + + '
' + + '
' + + '
'; + +function refreshSettings() { + var settingsItems = settingsPanel.getElementsByTagName("input"); + for (var i = 0; i < settingsItems.length; i++) { + var box = settingsItems[i]; + if (box.name in localStorage) { + box.checked = JSON.parse(localStorage[box.name]); + } else { + localStorage[box.name] = JSON.stringify(box.checked); + } + } +} + +function setupCheckbox(box) { + box.onchange = function(e) { + localStorage[box.name] = JSON.stringify(box.checked); + }; +} + +refreshSettings(); +var settingsItems = settingsPanel.getElementsByTagName("input"); +for (var i = 0; i < settingsItems.length; i++) { + setupCheckbox(settingsItems[i]); +} + +settingsPanel.onmouseover = function(e) { + refreshSettings(); + var settingsSections = settingsPanel.getElementsByTagName("div"); + settingsSections[0].style.fontWeight = "bold"; + settingsSections[1].style.display = "block"; +}; +settingsPanel.onmouseout = function(e) { + var settingsSections = settingsPanel.getElementsByTagName("div"); + settingsSections[0].style.fontWeight = "normal"; + settingsSections[1].style.display = "none"; +}; + +function setting(name) { + return JSON.parse(localStorage[name]); +} diff --git a/videodata.php b/videodata.php new file mode 100644 index 00000000..3fe9eeee --- /dev/null +++ b/videodata.php @@ -0,0 +1,131 @@ +name() == 'Cluster') { + $cluserTimecode = $x1->Get('Timecode'); + foreach($x1 as $x2) { + $blockRaw = NULL; + if ($x2->name() == 'SimpleBlock') { + $blockRaw = $x2->value(); + } elseif ($x2->name() == 'BlockGroup') { + $blockRaw = $x2->get('Block'); + } + if (isset($blockRaw)) { + $block = new MatroskaBlock($blockRaw); + if ($block->trackNumber == $trackNumber) { + $frame = $block->frames[0]; + if ($block->keyframe) { + if (!isset($cluserTimecode) || $cluserTimecode + $block->timecode >= $skip) { + return $frame; + } elseif (!isset($frame1)) { + $frame1 = $frame; + } + } + } + } + } + } + } + return isset($frame1) ? $frame1 : NULL; +} + +function videoData($filename) { + $data = array(); + + // Open file + $fileHandle = fopen($filename, 'rb'); + if (!$fileHandle) { + error_log('could not open file'); + return $data; + } + + try { + $root = readMatroska($fileHandle); + + // Locate segment information and tracks + $segment = $root->get('Segment'); + if (!isset($segment)) throw new Exception('missing Segment element'); + + // Get segment information + $info = $segment->get('Info'); + if (isset($info)) { + $timecodeScale = $info->get('TimecodeScale'); + $duration = $info->get('Duration'); + if (isset($timecodeScale) && isset($duration)) { + $data['duration'] = 1e-9 * $timecodeScale * $duration; + } + } + + // Locate video track + $tracks = $segment->get('Tracks'); + if (!isset($tracks)) throw new Exception('missing Tracks element'); + foreach($tracks as $trackEntry) { + if ($trackEntry->name() == 'TrackEntry' && $trackEntry->get('TrackType') == 1) { + $videoTrack = $trackEntry; + break; + } + } + if (!isset($videoTrack)) throw new Exception('no video track'); + + // Get track information + $videoAttr = $videoTrack->get('Video'); + if (isset($videoAttr)) { + $pixelWidth = $videoAttr->get('PixelWidth'); + $pixelHeight = $videoAttr->get('PixelHeight'); + if ($pixelWidth == 0 || $pixelHeight == 0) { + error_log('bad PixelWidth/PixelHeight'); + $pixelWidth = NULL; + $pixelHeight = NULL; + } + $data['width'] = $videoAttr->get('DisplayWidth', $pixelWidth); + $data['height'] = $videoAttr->get('DisplayHeight', $pixelHeight); + if ($data['width'] == 0 || $data['height'] == 0) { + error_log('bad DisplayWidth/DisplayHeight'); + $data['width'] = $pixelWidth; + $data['height'] = $pixelHeight; + } + } + + // Extract frame to use as thumbnail + $trackNumber = $videoTrack->get('TrackNumber'); + if (!isset($trackNumber)) throw new Exception('missing track number'); + $codecID = $videoTrack->get('CodecID'); + if ($codecID != 'V_VP8' && $codecID != 'V_VP9') throw new Exception('codec is not VP8 or VP9'); + if (!isset($pixelWidth) || !isset($pixelHeight)) throw new Exception('no width or height'); + if (isset($data['duration']) && $data['duration'] >= 5) { + $skip = 1e9 / $timecodeScale; + } else { + $skip = 0; + } + $frame = firstVPxFrame($segment, $trackNumber, $skip); + if (!isset($frame)) throw new Exception('no keyframes'); + $data['frame'] = vpxFrameHeader($frame->size(), $pixelWidth, $pixelHeight, $codecID) . $frame->readAll(); + + } catch (Exception $e) { + error_log($e->getMessage()); + } + + fclose($fileHandle); + return $data; +}