diff --git a/app/models/Inline.php b/app/models/Inline.php new file mode 100755 index 0000000..48fae05 --- /dev/null +++ b/app/models/Inline.php @@ -0,0 +1,115 @@ + [ + 'user' + ], + 'has_many' => [ + 'inline_images' => [function() { $this->order('sequence'); }, 'dependent' => 'destroy', 'class_name' => 'inlineImage'] + ] + ]; + } + + # Sequence numbers must start at 1 and increase monotonically, to keep the UI simple. + # If we've been given sequences with gaps or duplicates, sanitize them. + public function renumber_sequences() + { + $first = 1; + foreach ($this->inline_images as $image) { + $image->sequence = $first; + $image->save(); + $first++; + } + } + + public function pretty_name() + { + return 'x'; + } + + public function crop(array $params = []) + { + # MI: set default params + $params = array_merge([ + 'top' => 0, + 'bottom' => 0, + 'left' => 0, + 'right' => 0, + ], $params); + + if ($params['top'] < 0 or $params['top'] > 1 or + $params['bottom'] < 0 or $params['bottom'] > 1 or + $params['left'] < 0 or $params['left'] > 1 or + $params['right'] < 0 or $params['right'] > 1 or + $params['top'] >= $params['bottom'] or + $params['left'] >= $params['right'] + ) { + $this->errors()->add('parameter', 'error'); + return false; + } + + $images = $this->inline_images; + foreach ($images as $image) { + # Create a new image with the same properties, crop this image into the new one, + # and delete the old one. + $new_image = new InlineImage([ + 'description' => $image->description, + 'sequence' => $image->sequence, + 'inline_id' => $this->id, + 'file_ext' => 'jpg' + ]); + $size = $this->reduce_and_crop($image->width, $image->height, $params); + + try { + # Create one crop for the image, and InlineImage will create the sample and preview from that. + Moebooru\Reizer::resize($image->file_ext, $image->file_path(), $new_image->tempfile_image_path(), $size, 95); + chmod($new_image->tempfile_image_path(), 0775); + } catch (Exception $e) { + if (is_file($new_image->tempfile_image_path())) { + unlink($new_image->tempfile_image_path()); + } + + $this->errors()->add('crop', "couldn't be genrated (" . $e->getMessage() . ")"); + return false; + } + + $new_image->got_file(); + $new_image->save(); + $image->destroy(); + } + } + + public function api_attributes() + { + return [ + 'id' => $this->id, + 'description' => $this->description, + 'user_id' => $this->user_id, + 'images' => $this->inline_images->toArray() + ]; + } + + public function asJson(array $params = []) + { + return $this->api_attributes(); + } + + protected function reduce_and_crop($image_width, $image_height, array $params = []) + { + $cropped_image_width = $image_width * ($params['right'] - $params['left']); + $cropped_image_height = $image_height * ($params['bottom'] - $params['top']); + + $size = []; + $size['width'] = $cropped_image_width; + $size['height'] = $cropped_image_height; + $size['crop_top'] = $image_height * $params['top']; + $size['crop_bottom'] = $image_height * $params['bottom']; + $size['crop_left'] = $image_width * $params['left']; + $size['crop_right'] = $image_width * $params['right']; + + return $size; + } +} diff --git a/app/models/InlineImage.php b/app/models/InlineImage.php new file mode 100755 index 0000000..f487509 --- /dev/null +++ b/app/models/InlineImage.php @@ -0,0 +1,413 @@ +InlineImage. + * + * InlineImages can be posted directly in the forum and wiki (and possibly comments). + * + * An inline image can have three versions, like a post. For consistency, they use the + * same names: image, sample, preview. As with posts, sample and previews are always JPEG, + * and the dimensions of preview is derived from image rather than stored. + * + * Image files are effectively garbage collected: InlineImages can share files, and the file + * is deleted when the last one using it is deleted. This allows any user to copy another user's + * InlineImage, to crop it or to include it in an Inline. + * + * Example use cases: + * + * - Plain inlining, eg. for tutorials. Thumbs and larger images can be shown inline, allowing + * a click to expand. + * - Showing edits. Each user can upload his edit as an InlineImage and post it directly + * into the forum. + * - Comparing edits. A user can upload his own edit, pair it with another version (using + * Inline), crop to a region of interest, and post that inline. The images can then be + * compared in-place. This can be used to clearly show editing problems and differences. + */ +class InlineImage extends Rails\ActiveRecord\Base +{ + public $source; + + public $received_file; + + public $file_needs_move; + + protected function associations() + { + return [ + 'belongs_to' => [ + 'inline' + ] + ]; + } + + protected function callbacks() + { + return [ + 'before_validation_on_create' => [ + 'download_source', + 'determine_content_type', + 'set_image_dimensions', + 'generate_sample', + 'generate_preview', + 'move_file', + 'set_default_sequence' + ], + 'after_destroy' => [ + 'delete_file' + ], + 'before_create' => [ + 'validate_uniqueness' + ] + ]; + } + + public function tempfile_image_path() + { + return $this->temfile_prefix . '.upload'; + } + + public function tempfile_sample_path() + { + return $this->temfile_prefix . '-sample.upload'; + } + + public function tempfile_preview_path() + { + return $this->temfile_prefix . '-preview.upload'; + } + + /** + * MI: Warning for Windows: + * The PHP function symlink only works on Windows Vista, Server 2008 or greater. + */ + public function setPostId($id) + { + $post = Post::find($id); + $file = $post->file_path(); + + symlink($file, $this->tempfile_image_path()); + + $this->received_file = true; + $this->md5 = $post->md5; + } + + # Call once a file is available in tempfile_image_path. + public function got_file() + { + $this->generate_hash($this->tempfile_image_path()); + chmod($this->tempfile_image_path(), 0775); + $this->file_needs_move = true; + $this->received_file = true; + } + + /** + * @param string $f + */ + public function setFile($f) + { + if (!is_file($f) || !filesize($f)) { + return; + } + + copy($f, $this->tempfile_image_path()); + + $this->got_file(); + } + + public function download_source() + { + if (!preg_match('/^https?:\/\//', $this->source) || $this->file_ext + || $this->received_file + ) { + return; + } + + try { + $file = Danbooru::http_get_streaming($this->source); + file_put_contents($this->tempfile_image_path(), $file); + unset($file); + + $this->got_file(); + } catch (Exception $e) { + $this->delete_tempfile(); + $this->errors()->add('source', "couldn't be opened: " . $e->getMessage()); + return false; + } + } + + public function determine_content_type() + { + if ($this->file_ext) { + return true; + } + + if (!file_exists($this->tempfile_image_path())) { + $this->errors()->add('base', "No file received"); + return false; + } + + $imgsize = getimagesize($this->tempfile_image_path()); + + $this->file_ext = strtolower(image_type_to_extension($imgsize[2], false)); + + if ($this->file_ext == 'jpeg') { + $this->file_ext = 'jpg'; + } + + if (!in_array($this->file_ext, ['jpg', 'png', 'gif'])) { + $this->errors()->add('file', 'is an invalid content type: ' . $this->file_ext); + } + + return true; + } + + public function set_image_dimensions() + { + if ($this->width and $this->height) { + return true; + } + + $imgsize = getimagesize($this->tempfile_image_path()); + $this->width = $imgsize[0]; + $this->height = $imgsize[1]; + + return true; + } + + public function preview_dimensions() + { + return Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => 150, 'height' => 150]); + } + + public function thumb_size() + { + return Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => 400, 'height' => 400]); + } + + public function generate_sample() + { + if (is_file($this->sample_path())) { + return true; + } + + # We can generate the sample image during upload or offline. Use tempfile_image_path + # if it exists, otherwise use file_path. + $path = $this->tempfile_image_path(); + if (!is_file($path)) { + $path = $this->file_path(); + } + if (!is_file($path)) { + $this->errors()->add('file', 'not found'); + return false; + } + + # If we're not reducing the resolution for the sample image, only reencode if the + # source image is above the reencode threshold. Anything smaller won't be reduced + # enough by the reencode to bother, so don't reencode it and save disk space. + $sample_size = Moebooru\Resizer::reduce_to(['width' => $this->width, 'height' => $this->height], ['width' => CONFIG()->inline_sample_width, 'height' => CONFIG()->inline_sample_height]); + if ($sample_size['width'] == $this->width && $sample_size['height'] == $this->height && filesize($path) < CONFIG()->sample_always_generate_size) { + return true; + } + + # If we already have a sample image, and the parameters havn't changed, + # don't regenerate it. + if ($sample_size['width'] == $this->sample_width && $sample_size['height'] == $this->sample_height) { + return true; + } + + try { + Moebooru\Resizer::resize($this->file_ext, $path, $this->tempfile_sample_path(), $sample_size, 95); + } catch (Exception $e) { + $this->errors()->add('sample', "couldn't be created:" . $e->getMessage()); + return false; + } + + $this->sample_width = $sample_size['width']; + $this->sample_height = $sample_size['height']; + return true; + } + + public function generate_preview() + { + if (is_file($this->preview_path())) { + return true; + } + + if (!is_file($this->tempfile_image_path())) { + $this->errors()->add('file', 'not found'); + return false; + } + + # Generate the preview from the new sample if we have one to save CPU, otherwise from the image. + if (is_file($this->tempfile_sample_path())) { + $path = $this->tempfile_sample_path(); + $ext = 'jpg'; + } else { + $path = $this->tempfile_image_path(); + $ext = $this->file_ext; + } + + try { + Moebooru\Resizer::resize($ext, $path, $this->tempfile_preview_path(), $this->preview_dimensions(), 95); + } catch (Exception $e) { + $this->errors()->add('preview', "couldn't be generated: " . $e->getMessage()); + return false; + } + return true; + } + + public function move_file() + { + if (!$this->file_needs_move) { + return true; + } + + if (!is_dir(dirname($file_path))) { + mkdir(dirname($file_path), 0777, true); + } + rename($this->tempfile_image_path(), $this->file_path()); + + if (is_file($this->tempfile_preview_path())) { + if (!is_dir(dirname($this->preview_path()))) { + mkdir(dirname($this->preview_path()), 0777, true); + } + rename($this->tempfile_preview_path(), $this->preview_path()); + } + if (is_file($this->tempfile_sample_path())) { + if (!is_dir(dirname($this->sample_path()))) { + mkdir(dirname($this->sample_path()), 0777, true); + } + rename($this->tempfile_sample_path(), $this->sample_path()); + } + $this->file_needs_move = false; + return true; + } + + public function set_default_sequence() + { + if ($this->sequence) { + return; + } + $siblings = $this->inline->inline_images; + $max_sequence = max($siblings->getAttributes('sequence')) ?: 0; + $this->sequence = $max_sequence + 1; + } + + public function generate_hash($path) + { + $this->md5 = md5_file($path); + } + + public function has_sample() + { + return (bool)$this->sample_height; + } + + public function file_name() + { + return $this->md5 . '.' . $this->file_ext; + } + + public function file_name_jpg() + { + return $this->md5 . '.jpg'; + } + + public function file_path() + { + return Rails::publicPath() . '/data/inline/image/' . $this->file_name(); + } + + public function preview_path() + { + return Rails::publicPath() . '/data/inline/preview/' . $this->file_name_jpg(); + } + + public function sample_path() + { + return Rails::publicPath() . '/data/inline/sample/' . $this->file_name_jpg(); + } + + public function file_url() + { + return CONFIG()->url_base . '/data/inline/image/' . $this->file_name(); + } + + public function sample_url() + { + if ($this->has_sample()) { + return CONFIG()->url_base . '/data/inline/sample/' . $this->file_name_jpg(); + } else { + return $this->file_url(); + } + } + + public function preview_url() + { + return CONFIG()->url_base . '/data/inline/preview/' . $this->file_name_jpg(); + } + + public function delete_file() + { + # If several inlines use the same image, they'll share the same file via the MD5. Only + # delete the file if this is the last one using it. + $exists = InlineImage::where('id <> ? AND md5 = ?', $this->id, $this->md5)->first(); + if ($exists) { + return; + } + + if (is_file($this->file_path())) { + unlink($this->file_path()); + } + if (is_file($this->preview_path())) { + unlink($this->preview_path()); + } + if (is_file($this->sample_path())) { + unlink($this->sample_path()); + } + } + + # We should be able to use validates_uniqueness_of for this, but Rails is completely + # brain-damaged: it only lets you specify an error message that starts with the name + # of the column, capitalized, so if we say "foo", the message is "Md5 foo". This is + # useless. + public function validate_uniqueness() + { + $siblings = $this->inline->inline_images; + foreach ($siblings as $s) { + if ($s->id == $this->id) { + continue; + } + if ($s->md5 == $this->md5) { + $this->errors()->add('base', '#' . $s->sequence . ' already exists.'); + return false; + } + } + return true; + } + + public function api_attributes() + { + return [ + 'id' => $this->id, + 'sequence' => $this->sequence, + 'md5' => $this->md5, + 'width' => $this->width, + 'height' => $this->height, + 'sample_width' => $this->sample_width, + 'sample_height' => $this->sample_height, + 'preview_width' => $this->preview_dimensions()['width'], + 'preview_height' => $this->preview_dimensions()['height'], + 'description' => $this->description, + 'file_url' => $this->file_url(), + 'sample_url' => $this->sample_url(), + 'preview_url' => $this->preview_url() + ]; + } + + public function asJson() + { + return $this->api_attributes(); + } +} diff --git a/db/migrate/20131231072227_create_inline_images.php b/db/migrate/20131231072227_create_inline_images.php new file mode 100755 index 0000000..5015dfa --- /dev/null +++ b/db/migrate/20131231072227_create_inline_images.php @@ -0,0 +1,25 @@ +createTable('inlines', function($t) { + $t->integer('user_id', ['null' => false]); + $t->text('description'); + $t->timestamps(); + }); + + $this->createTable('inline_images', function($t) { + $t->integer('inline_id'); + $t->string('md5', 32); + $t->string('file_ext', 4); + $t->text('description'); + $t->integer('sequence'); + $t->integer('width'); + $t->integer('height'); + $t->integer('sample_width'); + $t->integer('sample_height'); + $t->timestamps(); + }); + } +}