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 00000000..cda6b337 Binary files /dev/null and b/collapse.gif differ diff --git a/expandvideo.js b/expandvideo.js new file mode 100644 index 00000000..4ecddd60 --- /dev/null +++ b/expandvideo.js @@ -0,0 +1,118 @@ +function setupVideo(thumb, url) { + var video = null; + var videoContainer, videoHide; + var expanded = false; + var hovering = false; + + function unexpand() { + if (expanded) { + expanded = false; + if (video.pause) video.pause(); + videoContainer.style.display = "none"; + thumb.style.display = "inline"; + } + } + + function unhover() { + if (hovering) { + hovering = false; + if (video.pause) video.pause(); + video.style.display = "none"; + } + } + + function getVideo() { + if (video == null) { + video = document.createElement("video"); + video.src = url; + video.loop = true; + video.innerText = "Your browser does not support HTML5 video."; + video.onclick = function(e) { + if (e.shiftKey) { + unexpand(); + e.preventDefault(); + } + }; + + videoHide = document.createElement("img"); + videoHide.src = configRoot + "cc/collapse.gif"; + videoHide.alt = "[ - ]"; + videoHide.title = "Collapse to thumbnail"; + videoHide.style.verticalAlign = "top"; + videoHide.style.marginRight = "2px"; + videoHide.onclick = unexpand; + + videoContainer = document.createElement("div"); + videoContainer.style.whiteSpace = "nowrap"; + videoContainer.appendChild(videoHide); + videoContainer.appendChild(video); + thumb.parentNode.insertBefore(videoContainer, thumb.nextSibling); + } + } + + thumb.onclick = function(e) { + if (setting("videoexpand") && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { + getVideo(); + expanded = true; + hovering = false; + + video.style.position = "static"; + video.style.maxWidth = ""; + video.style.maxHeight = ""; + + video.style.display = "inline"; + videoHide.style.display = "inline"; + videoContainer.style.display = "block"; + thumb.style.display = "none"; + + video.muted = setting("videomuted"); + video.controls = true; + video.play(); + return false; + } + }; + + thumb.onmouseover = function(e) { + if (setting("videohover")) { + getVideo(); + expanded = false; + hovering = true; + + video.style.position = "fixed"; + video.style.right = "0px"; + video.style.top = "0px"; + video.style.maxWidth = (document.body.parentNode.getBoundingClientRect().right - thumb.getBoundingClientRect().right) + "px"; + video.style.maxHeight = "100%"; + + video.style.display = "inline"; + videoHide.style.display = "none"; + videoContainer.style.display = "inline"; + + video.muted = setting("videomuted"); + video.controls = false; + video.play(); + } + }; + + thumb.onmouseout = unhover; +} + +window.onload = function() { + settingsPanel.style.position = "absolute"; + settingsPanel.style.top = "1em"; + settingsPanel.style.right = "1em"; + document.body.insertBefore(settingsPanel, document.body.firstChild); + + var thumbs = document.querySelectorAll("a.file"); + for (var i = 0; i < thumbs.length; i++) { + if (/\.webm$/.test(thumbs[i].pathname)) { + setupVideo(thumbs[i], thumbs[i].href); + } else { + var m = thumbs[i].search.match(/\bv=([^&]*)/); + if (m != null) { + var url = decodeURIComponent(m[1]); + if (/\.webm$/.test(url)) setupVideo(thumbs[i], url); + } + } + } +}; diff --git a/matroska-elements.txt b/matroska-elements.txt new file mode 100644 index 00000000..07f8eecc --- /dev/null +++ b/matroska-elements.txt @@ -0,0 +1,224 @@ +a45dfa3 container EBML root +286 uint EBMLVersion a45dfa3 +2f7 uint EBMLReadVersion a45dfa3 +2f2 uint EBMLMaxIDLength a45dfa3 +2f3 uint EBMLMaxSizeLength a45dfa3 +282 string DocType a45dfa3 +287 uint DocTypeVersion a45dfa3 +285 uint DocTypeReadVersion a45dfa3 +6c binary Void * +3f binary CRC-32 * +b538667 container SignatureSlot * +3e8a uint SignatureAlgo b538667 +3e9a uint SignatureHash b538667 +3ea5 binary SignaturePublicKey b538667 +3eb5 binary Signature b538667 +3e5b container SignatureElements b538667 +3e7b container SignatureElementList 3e5b +2532 binary SignedElement 3e7b +8538067 container Segment root +14d9b74 container SeekHead 8538067 +dbb container Seek 14d9b74 +13ab binary SeekID dbb +13ac uint SeekPosition dbb +549a966 container Info 8538067 +33a4 binary SegmentUID 549a966 +3384 string SegmentFilename 549a966 +1cb923 binary PrevUID 549a966 +1c83ab string PrevFilename 549a966 +1eb923 binary NextUID 549a966 +1e83bb string NextFilename 549a966 +444 binary SegmentFamily 549a966 +2924 container ChapterTranslate 549a966 +29fc uint ChapterTranslateEditionUID 2924 +29bf uint ChapterTranslateCodec 2924 +29a5 binary ChapterTranslateID 2924 +ad7b1 uint TimecodeScale 549a966 +489 float Duration 549a966 +461 date DateUTC 549a966 +3ba9 string Title 549a966 +d80 string MuxingApp 549a966 +1741 string WritingApp 549a966 +f43b675 container Cluster 8538067 +67 uint Timecode f43b675 +1854 container SilentTracks f43b675 +18d7 uint SilentTrackNumber 1854 +27 uint Position f43b675 +2b uint PrevSize f43b675 +23 binary SimpleBlock f43b675 +20 container BlockGroup f43b675 +21 binary Block 20 +22 binary BlockVirtual 20 +35a1 container BlockAdditions 20 +26 container BlockMore 35a1 +6e uint BlockAddID 26 +25 binary BlockAdditional 26 +1b uint BlockDuration 20 +7a uint ReferencePriority 20 +7b int ReferenceBlock 20 +7d int ReferenceVirtual 20 +24 binary CodecState 20 +35a2 int DiscardPadding 20 +e container Slices 20 +68 container TimeSlice e +4c uint LaceNumber 68 +4d uint FrameNumber 68 +4b uint BlockAdditionID 68 +4e uint Delay 68 +4f uint SliceDuration 68 +48 container ReferenceFrame 20 +49 uint ReferenceOffset 48 +4a uint ReferenceTimeCode 48 +2f binary EncryptedBlock f43b675 +654ae6b container Tracks 8538067 +2e container TrackEntry 654ae6b +57 uint TrackNumber 2e +33c5 uint TrackUID 2e +3 uint TrackType 2e +39 uint FlagEnabled 2e +8 uint FlagDefault 2e +15aa uint FlagForced 2e +1c uint FlagLacing 2e +2de7 uint MinCache 2e +2df8 uint MaxCache 2e +3e383 uint DefaultDuration 2e +34e7a uint DefaultDecodedFieldDuration 2e +3314f float TrackTimecodeScale 2e +137f int TrackOffset 2e +15ee uint MaxBlockAdditionID 2e +136e string Name 2e +2b59c string Language 2e +6 string CodecID 2e +23a2 binary CodecPrivate 2e +58688 string CodecName 2e +3446 uint AttachmentLink 2e +1a9697 string CodecSettings 2e +1b4040 string CodecInfoURL 2e +6b240 string CodecDownloadURL 2e +2a uint CodecDecodeAll 2e +2fab uint TrackOverlay 2e +16aa uint CodecDelay 2e +16bb uint SeekPreRoll 2e +2624 container TrackTranslate 2e +26fc uint TrackTranslateEditionUID 2624 +26bf uint TrackTranslateCodec 2624 +26a5 binary TrackTranslateTrackID 2624 +60 container Video 2e +1a uint FlagInterlaced 60 +13b8 uint StereoMode 60 +13c0 uint AlphaMode 60 +13b9 uint OldStereoMode 60 +30 uint PixelWidth 60 +3a uint PixelHeight 60 +14aa uint PixelCropBottom 60 +14bb uint PixelCropTop 60 +14cc uint PixelCropLeft 60 +14dd uint PixelCropRight 60 +14b0 uint DisplayWidth 60 +14ba uint DisplayHeight 60 +14b2 uint DisplayUnit 60 +14b3 uint AspectRatioType 60 +eb524 binary ColourSpace 60 +fb523 float GammaValue 60 +383e3 float FrameRate 60 +61 container Audio 2e +35 float SamplingFrequency 61 +38b5 float OutputSamplingFrequency 61 +1f uint Channels 61 +3d7b binary ChannelPositions 61 +2264 uint BitDepth 61 +62 container TrackOperation 2e +63 container TrackCombinePlanes 62 +64 container TrackPlane 63 +65 uint TrackPlaneUID 64 +66 uint TrackPlaneType 64 +69 container TrackJoinBlocks 62 +6d uint TrackJoinUID 69 +40 uint TrickTrackUID 2e +41 binary TrickTrackSegmentUID 2e +46 uint TrickTrackFlag 2e +47 uint TrickMasterTrackUID 2e +44 binary TrickMasterTrackSegmentUID 2e +2d80 container ContentEncodings 2e +2240 container ContentEncoding 2d80 +1031 uint ContentEncodingOrder 2240 +1032 uint ContentEncodingScope 2240 +1033 uint ContentEncodingType 2240 +1034 container ContentCompression 2240 +254 uint ContentCompAlgo 1034 +255 binary ContentCompSettings 1034 +1035 container ContentEncryption 2240 +7e1 uint ContentEncAlgo 1035 +7e2 binary ContentEncKeyID 1035 +7e3 binary ContentSignature 1035 +7e4 binary ContentSigKeyID 1035 +7e5 uint ContentSigAlgo 1035 +7e6 uint ContentSigHashAlgo 1035 +c53bb6b container Cues 8538067 +3b container CuePoint c53bb6b +33 uint CueTime 3b +37 container CueTrackPositions 3b +77 uint CueTrack 37 +71 uint CueClusterPosition 37 +70 uint CueRelativePosition 37 +32 uint CueDuration 37 +1378 uint CueBlockNumber 37 +6a uint CueCodecState 37 +5b container CueReference 37 +16 uint CueRefTime 5b +17 uint CueRefCluster 5b +135f uint CueRefNumber 5b +6b uint CueRefCodecState 5b +941a469 container Attachments 8538067 +21a7 container AttachedFile 941a469 +67e string FileDescription 21a7 +66e string FileName 21a7 +660 string FileMimeType 21a7 +65c binary FileData 21a7 +6ae uint FileUID 21a7 +675 binary FileReferral 21a7 +661 uint FileUsedStartTime 21a7 +662 uint FileUsedEndTime 21a7 +43a770 container Chapters 8538067 +5b9 container EditionEntry 43a770 +5bc uint EditionUID 5b9 +5bd uint EditionFlagHidden 5b9 +5db uint EditionFlagDefault 5b9 +5dd uint EditionFlagOrdered 5b9 +36 container ChapterAtom 5b9 36 +33c4 uint ChapterUID 36 +1654 string ChapterStringUID 36 +11 uint ChapterTimeStart 36 +12 uint ChapterTimeEnd 36 +18 uint ChapterFlagHidden 36 +598 uint ChapterFlagEnabled 36 +2e67 binary ChapterSegmentUID 36 +2ebc uint ChapterSegmentEditionUID 36 +23c3 uint ChapterPhysicalEquiv 36 +f container ChapterTrack 36 +9 uint ChapterTrackNumber f +0 container ChapterDisplay 36 +5 string ChapString 0 +37c string ChapLanguage 0 +37e string ChapCountry 0 +2944 container ChapProcess 36 +2955 uint ChapProcessCodecID 2944 +50d binary ChapProcessPrivate 2944 +2911 container ChapProcessCommand 2944 +2922 uint ChapProcessTime 2911 +2933 binary ChapProcessData 2911 +254c367 container Tags 8538067 +3373 container Tag 254c367 +23c0 container Targets 3373 +28ca uint TargetTypeValue 23c0 +23ca string TargetType 23c0 +23c5 uint TagTrackUID 23c0 +23c9 uint TagEditionUID 23c0 +23c4 uint TagChapterUID 23c0 +23c6 uint TagAttachmentUID 23c0 +27c8 container SimpleTag 3373 27c8 +5a3 string TagName 27c8 +47a string TagLanguage 27c8 +484 uint TagDefault 27c8 +487 string TagString 27c8 +485 binary TagBinary 27c8 diff --git a/matroska.php b/matroska.php new file mode 100644 index 00000000..6e70d995 --- /dev/null +++ b/matroska.php @@ -0,0 +1,487 @@ +datatype = $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; +}