Adding support to Inline Images
This commit is contained in:
parent
ba948f46a7
commit
33e86da790
115
app/models/Inline.php
Executable file
115
app/models/Inline.php
Executable file
@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
class Inline extends Rails\ActiveRecord\Base
|
||||||
|
{
|
||||||
|
protected function assotiacions()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'belongs_to' => [
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
}
|
413
app/models/InlineImage.php
Executable file
413
app/models/InlineImage.php
Executable file
@ -0,0 +1,413 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* InlineImages can be uploaded, copied directly from posts, or cropped from other InlineImages.
|
||||||
|
* To create an image by cropping a post, the post must be copied to an InlineImage of its own,
|
||||||
|
* and cropped from there; the only UI for cropping is InlineImage->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();
|
||||||
|
}
|
||||||
|
}
|
25
db/migrate/20131231072227_create_inline_images.php
Executable file
25
db/migrate/20131231072227_create_inline_images.php
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
class CreateInlineImages extends Rails\ActiveRecord\Migration\Base
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user