Sequenzia/app/models/InlineImage.php
Parziphal cf9cba40b6 Fully enabled Inline Images.
A little detail: the "Add image" button was moved to a new table row, so it stands out more.
Known bugs/errors:
- When cropping an animated GIF inline (with a secondary JPG inline), although the images are correctly cropped, an empty error is displayed.
2013-12-31 14:22:59 -05:00

418 lines
13 KiB
PHP
Executable File

<?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
{
use Moebooru\TempfilePrefix;
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->tempfile_prefix() . '.upload';
}
public function tempfile_sample_path()
{
return $this->tempfile_prefix() . '-sample.upload';
}
public function tempfile_preview_path()
{
return $this->tempfile_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 Rails\ActionDispatch\Http\UploadedFile $f
*/
public function setFile($f)
{
if (!$f->size()) {
return;
}
copy($f->tempName(), $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()->addToBase("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 ?: 'unknown');
}
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($this->file_path()))) {
mkdir(dirname($this->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 = ($siblings->getAttributes('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' => (int)$this->id,
'sequence' => $this->sequence,
'md5' => $this->md5,
'width' => (int)$this->width,
'height' => (int)$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' => (string)$this->description,
'file_url' => $this->file_url(),
'sample_url' => $this->sample_url(),
'preview_url' => $this->preview_url()
];
}
public function asJson()
{
return $this->api_attributes();
}
}