Massive refactoring of the image processing, so it's now driven by the front-end and we can completely remove the command-line tasks - which will allow the app to work completely encoded using SourceGuardian and domain-locking.

This commit is contained in:
Andy Heathershaw 2016-09-08 23:22:29 +01:00
parent 821bfceb09
commit 56cfade23c
26 changed files with 759 additions and 704 deletions

View File

@ -30,15 +30,6 @@ class Album extends Model
protected $hidden = [
];
public function fromRequest(Request $request)
{
$this->name = $request->get('name');
$this->description = $request->get('description');
$this->generateAlias();
return $this;
}
public function generateAlias()
{
$this->url_alias = ucfirst(preg_replace('/[^a-z0-9\-]/', '-', strtolower($this->name)));
@ -50,7 +41,7 @@ class Album extends Model
public function getAlbumSource()
{
// TODO allow albums to specify different storage locations - e.g. Amazon S3, SFTP/FTP, OpenStack
return new LocalFilesystemSource(dirname(__DIR__) . '/storage/app/albums');
return new LocalFilesystemSource($this, dirname(__DIR__) . '/storage/app/albums');
}
public function photos()
@ -66,7 +57,7 @@ class Album extends Model
if (!is_null($photo))
{
return $this->getAlbumSource()->getUrlToPhoto($this, $photo, $thumbnailName);
return $this->getAlbumSource()->getUrlToPhoto($photo, $thumbnailName);
}
return '';

View File

@ -12,37 +12,33 @@ interface IAlbumSource
/**
* Gets the absolute path to the given photo file.
* @param Album $album Album containing the photo.
* @param Photo $photo Photo to get the path to.
* @param string $thumbnail Thumbnail to get the image to.
* @return string
*/
function getPathToPhoto(Album $album, Photo $photo, $thumbnail = null);
function getPathToPhoto(Photo $photo, $thumbnail = null);
/**
* Gets the absolute URL to the given photo file.
* @param Album $album Album containing the photo.
* @param Photo $photo Photo to get the URL to.
* @param string $thumbnail Thumbnail to get the image to.
* @return string
*/
function getUrlToPhoto(Album $album, Photo $photo, $thumbnail = null);
function getUrlToPhoto(Photo $photo, $thumbnail = null);
/**
* Saves a generated thumbnail to its permanent location.
* @param Album $album Album containing the photo.
* @param Photo $photo Photo the thumbnail relates to.
* @param string $thumbnailInfo Information about the thumbnail.
* @param string $tempFilename Filename containing the thumbnail.
* @return mixed
*/
function saveThumbnail(Album $album, Photo $photo, $thumbnailInfo, $tempFilename);
function saveThumbnail(Photo $photo, $thumbnailInfo, $tempFilename);
/**
* Saves an uploaded file to the container and returns the filename.
* @param Album $album The album containing the photo
* @param File $uploadedFile The photo uploaded
* @return File
*/
function saveUploadedPhoto(Album $album, File $uploadedFile);
function saveUploadedPhoto(File $uploadedFile);
}

View File

@ -13,10 +13,19 @@ use Symfony\Component\HttpFoundation\File\File;
*/
class LocalFilesystemSource implements IAlbumSource
{
/**
* @var Album
*/
private $album;
/**
* @var string
*/
private $parentFolder;
public function __construct($parentFolder)
public function __construct(Album $album, $parentFolder)
{
$this->album = $album;
$this->parentFolder = $parentFolder;
}
@ -25,20 +34,20 @@ class LocalFilesystemSource implements IAlbumSource
return '_originals';
}
public function getPathToPhoto(Album $album, Photo $photo, $thumbnail = null)
public function getPathToPhoto(Photo $photo, $thumbnail = null)
{
if (is_null($thumbnail))
{
$thumbnail = $this->getOriginalsFolder();
}
return sprintf('%s/%s/%s', $this->getPathToAlbum($album), $thumbnail, $photo->storage_file_name);
return sprintf('%s/%s/%s', $this->getPathToAlbum(), $thumbnail, $photo->storage_file_name);
}
public function getUrlToPhoto(Album $album, Photo $photo, $thumbnail = null)
public function getUrlToPhoto(Photo $photo, $thumbnail = null)
{
$photoUrl = route('downloadPhoto', [
'albumUrlAlias' => $album->url_alias,
'albumUrlAlias' => $this->album->url_alias,
'photoFilename' => $photo->storage_file_name
]);
@ -50,15 +59,15 @@ class LocalFilesystemSource implements IAlbumSource
return $photoUrl;
}
public function saveThumbnail(Album $album, Photo $photo, $thumbnailInfo, $tempFilename)
public function saveThumbnail(Photo $photo, $thumbnailInfo, $tempFilename)
{
$fileInfo = new File($tempFilename);
$fileInfo->move(sprintf('%s/%s', $this->getPathToAlbum($album), $thumbnailInfo['name']), $photo->storage_file_name);
$fileInfo->move(sprintf('%s/%s', $this->getPathToAlbum(), $thumbnailInfo['name']), $photo->storage_file_name);
}
public function saveUploadedPhoto(Album $album, File $uploadedFile)
public function saveUploadedPhoto(File $uploadedFile)
{
$tempFilename = sprintf('%s/%s/%s', $this->getPathToAlbum($album), $this->getOriginalsFolder(), MiscHelper::randomString(20));
$tempFilename = sprintf('%s/%s/%s', $this->getPathToAlbum(), $this->getOriginalsFolder(), MiscHelper::randomString(20));
$extension = $uploadedFile->guessExtension();
if (!is_null($extension))
@ -70,8 +79,8 @@ class LocalFilesystemSource implements IAlbumSource
return new File($tempFilename);
}
private function getPathToAlbum(Album $album)
private function getPathToAlbum()
{
return sprintf('%s/%s', $this->parentFolder, $album->url_alias);
return sprintf('%s/%s', $this->parentFolder, $this->album->url_alias);
}
}

View File

@ -1,234 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Album;
use App\Helpers\ImageHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
use App\Upload;
use App\UploadPhoto;
use Illuminate\Console\Command;
class ProcessUploadCommand extends Command
{
const METADATA_VERSION = 1;
/**
* @var ImageHelper
*/
private $imageHelper;
/**
* @var ThemeHelper
*/
private $themeHelper;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'twilight:process-uploads';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Processes uploads made through the web application.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(ImageHelper $imageHelper, ThemeHelper $themeHelper)
{
parent::__construct();
$this->imageHelper = $imageHelper;
$this->themeHelper = $themeHelper;
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$uploadsToProcess = Upload::where([
['is_completed', false],
['is_processing', false],
['is_ready', true]
])
->orderBy('created_at')
->get();
/** @var Upload $upload */
foreach ($uploadsToProcess as $upload)
{
$upload->is_processing = 1;
$upload->save();
$this->output->writeln(sprintf('Processing upload #%d', $upload->id));
$this->handleUpload($upload);
$upload->is_completed = 1;
$upload->is_processing = 0;
$upload->save();
}
}
private function handleUpload(Upload $upload)
{
$photos = $upload->uploadPhotos;
/** @var UploadPhoto $photo */
foreach ($photos as $photo)
{
try
{
$this->handlePhoto($photo);
$upload->number_successful++;
}
catch (\Exception $ex)
{
$upload->number_failed++;
$photo->photo->delete();
$photo->delete();
}
$upload->save();
}
}
private function handlePhoto(UploadPhoto $uploadPhoto)
{
/** @var Photo $photo */
$photo = $uploadPhoto->photo;
/** @var Album $album */
$album = $photo->album;
$albumSource = $album->getAlbumSource();
$photoFile = $albumSource->getPathToPhoto($album, $photo);
// Read and analyse Exif data
$this->output->writeln(sprintf('Analysing photo #%d: %s', $photo->id, $photo->name));
// Open the photo
$imageInfo = null;
$originalPhotoResource = $this->imageHelper->openImage($photoFile, $imageInfo);
$photo->width = $imageInfo[0];
$photo->height = $imageInfo[1];
$photo->mime_type = $imageInfo['mime'];
// Read the Exif data
$exifData = @exif_read_data($photoFile);
$isExifDataFound = ($exifData !== false && is_array($exifData));
$angleToRotate = 0;
if ($isExifDataFound && isset($exifData['Orientation']))
{
switch ($exifData['Orientation'])
{
case 3:
$angleToRotate = 180;
break;
case 6:
$angleToRotate = 270;
break;
case 8:
$angleToRotate = 90;
break;
}
if ($angleToRotate > 0)
{
$originalPhotoResource = $this->imageHelper->rotateImage($originalPhotoResource, $angleToRotate);
if ($angleToRotate == 90 || $angleToRotate == 270)
{
$photo->width = $imageInfo[1];
$photo->height = $imageInfo[0];
}
}
}
if ($isExifDataFound)
{
$photo->metadata_version = ProcessUploadCommand::METADATA_VERSION;
$photo->taken_at = $this->metadataDateTime($exifData);
$photo->camera_make = $this->metadataCameraMake($exifData);
$photo->camera_model = $this->metadataCameraModel($exifData);
$photo->camera_software = $this->metadataCameraSoftware($exifData);
$photo->rotation = $angleToRotate;
}
$photo->save();
// Generate and save thumbnails
$this->output->writeln('Generating thumbnails');
$themeInfo = $this->themeHelper->info();
$thumbnailsRequired = $themeInfo['thumbnails'];
/** @var mixed $thumbnail */
foreach ($thumbnailsRequired as $thumbnail)
{
$generatedThumbnailPath = $this->imageHelper->generateThumbnail($originalPhotoResource, $photo, $thumbnail);
$albumSource->saveThumbnail($album, $photo, $thumbnail, $generatedThumbnailPath);
$this->output->writeln(sprintf('Thumbnail \'%s\' (%dx%d) created', $thumbnail['name'], $thumbnail['width'], $thumbnail['height']));
}
}
private function metadataCameraMake(array $exifData)
{
if (isset($exifData['Make']))
{
return $exifData['Make'];
}
return null;
}
private function metadataCameraModel(array $exifData)
{
if (isset($exifData['Model']))
{
return $exifData['Model'];
}
return null;
}
private function metadataCameraSoftware(array $exifData)
{
if (isset($exifData['Software']))
{
return $exifData['Software'];
}
return null;
}
private function metadataDateTime(array $exifData)
{
$dateTime = null;
if (isset($exifData['DateTime']))
{
$dateTime = $exifData['DateTime'];
}
if (!is_null($dateTime))
{
$dateTime = preg_replace('/^([\d]{4}):([\d]{2}):([\d]{2})/', '$1-$2-$3', $dateTime);
}
return $dateTime;
}
}

View File

@ -1,120 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Album;
use App\Helpers\ImageHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
use Illuminate\Console\Command;
class RegenerateThumbnailsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'twilight:regenerate-thumbnails {--album} {id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Recreates thumbnails for an image or an album.';
/**
* @var ImageHelper
*/
private $imageHelper;
/**
* @var ThemeHelper
*/
private $themeHelper;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(ImageHelper $imageHelper, ThemeHelper $themeHelper)
{
parent::__construct();
$this->imageHelper = $imageHelper;
$this->themeHelper = $themeHelper;
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$id = intval($this->argument('id'));
if ($this->option('album'))
{
$this->handleAlbum($id);
}
else
{
$this->handlePhoto($id);
}
}
private function handleAlbum($id)
{
$album = Album::where('id', $id)->first();
if (is_null($album))
{
throw new \Exception(sprintf('The album with ID %d could not be found', $id));
}
/** @var Photo $photo */
foreach ($album->photos as $photo)
{
$this->regenerateThumbnailsForPhoto($album, $photo);
}
}
private function handlePhoto($id)
{
$photo = Photo::where('id', $id)->first();
if (is_null($photo))
{
throw new \Exception(sprintf('The photo with ID %d could not be found', $id));
}
/** @var Album $album */
$album = $photo->album;
$this->regenerateThumbnailsForPhoto($album, $photo);
}
private function regenerateThumbnailsForPhoto(Album $album, Photo $photo)
{
$albumSource = $album->getAlbumSource();
$originalPhotoResource = $this->imageHelper->openImage($albumSource->getPathToPhoto($album, $photo), $imageInfo);
if (!is_resource($originalPhotoResource))
{
throw new \Exception(sprintf('The original image for photo ID %d could not be found', $id));
}
$this->output->writeln(sprintf('Generating thumbnails for "%s"...', $photo->file_name));
$themeInfo = $this->themeHelper->info();
$thumbnailsRequired = $themeInfo['thumbnails'];
/** @var mixed $thumbnail */
foreach ($thumbnailsRequired as $thumbnail)
{
$generatedThumbnailPath = $this->imageHelper->generateThumbnail($originalPhotoResource, $photo, $thumbnail);
$albumSource->saveThumbnail($album, $photo, $thumbnail, $generatedThumbnailPath);
$this->output->writeln(sprintf('Thumbnail \'%s\' (%dx%d) created', $thumbnail['name'], $thumbnail['width'], $thumbnail['height']));
}
}
}

View File

@ -16,8 +16,6 @@ class Kernel extends ConsoleKernel
* @var array
*/
protected $commands = [
ProcessUploadCommand::class,
RegenerateThumbnailsCommand::class
];
/**
@ -28,15 +26,6 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('twilight:process-uploads')
->everyMinute()
->when(function () {
return (Upload::where([
'is_completed' => 0,
'is_processing' => 0,
'is_ready' => 1
])->count() > 0);
});
}
/**

18
app/Facade/Image.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Facade;
use Illuminate\Support\Facades\Facade;
class Image extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'image';
}
}

View File

@ -49,7 +49,6 @@ class ImageHelper
public function openImage($imagePath, &$imageInfo)
{
$imageInfo = getimagesize($imagePath);
if ($imageInfo === false)
{
throw new \Exception(sprintf('The image "%s" does not appear to be a valid image, or cannot be read', pathinfo($imagePath, PATHINFO_FILENAME)));
@ -97,4 +96,26 @@ class ImageHelper
return imagerotate($imageResource, $angle, 0);
}
public function saveImage($imageResource, $imagePath, $imageInfo)
{
switch ($imageInfo[2])
{
case IMG_GIF:
imagegif($imageResource, $imagePath);
break;
case IMG_JPEG:
imagejpeg($imageResource, $imagePath);
break;
case IMG_PNG:
imagepng($imageResource, $imagePath);
break;
case IMG_WBMP:
imagewbmp($imageResource, $imagePath);
break;
}
}
}

View File

@ -14,6 +14,71 @@ use Illuminate\Support\Facades\DB;
class AlbumController extends Controller
{
public function analyse($id)
{
$this->authorize('admin-access');
$album = $this->loadAlbum($id);
$photos = $album->photos()
->where('is_analysed', false)
->orderBy('created_at')
->get();
return Theme::render('admin.album_analyse_progress', ['album' => $album, 'photos' => $photos]);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$this->authorize('admin-access');
return Theme::render('admin.create_album');
}
public function delete($id)
{
$this->authorize('admin-access');
$album = $this->loadAlbum($id);
return Theme::render('admin.delete_album', ['album' => $album]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
$this->authorize('admin-access');
$album = $this->loadAlbum($id);
$album->delete();
return redirect(route('albums.index'));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$this->authorize('admin-access');
$album = $this->loadAlbum($id);
return Theme::render('admin.edit_album', ['album' => $album]);
}
/**
* Display a listing of the resource.
*
@ -32,27 +97,6 @@ class AlbumController extends Controller
]);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$this->authorize('admin-access');
return Theme::render('admin.create_album');
}
public function delete($id)
{
$this->authorize('admin-access');
$album = AlbumController::loadAlbum($id);
return Theme::render('admin.delete_album', ['album' => $album]);
}
/**
* Display the specified resource.
*
@ -63,7 +107,7 @@ class AlbumController extends Controller
{
$this->authorize('admin-access');
$album = AlbumController::loadAlbum($id);
$album = $this->loadAlbum($id);
$photos = $album->photos()
->orderBy(DB::raw('COALESCE(taken_at, created_at)'))
->paginate(UserConfig::get('items_per_page_admin'));
@ -86,42 +130,13 @@ class AlbumController extends Controller
$this->authorize('admin-access');
$album = new Album();
$album->fromRequest($request)->save();
$album->fill($request->only(['name', 'description']));
$album->generateAlias();
$album->save();
return redirect(route('albums.index'));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$this->authorize('admin-access');
$album = AlbumController::loadAlbum($id);
return Theme::render('admin.edit_album', ['album' => $album]);
}
public function monitorUpload($id, $uploadId)
{
$this->authorize('admin-access');
$upload = AlbumController::loadUpload($uploadId, $id);
return Theme::render('admin.album_upload_progress', ['upload' => $upload, 'album' => $upload->album]);
}
public function monitorUploadJson($id, $uploadId)
{
$this->authorize('admin-access');
return response()->json(AlbumController::loadUpload($uploadId, $id)->toArray());
}
/**
* Update the specified resource in storage.
*
@ -133,33 +148,18 @@ class AlbumController extends Controller
{
$this->authorize('admin-access');
$album = AlbumController::loadAlbum($id);
$album->fromRequest($request)->save();
$album = $this->loadAlbum($id);
$album->fill($request->only(['name', 'description']));
$album->save();
return Theme::render('admin.show_album', ['album' => $album]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
$this->authorize('admin-access');
$album = $this->loadAlbum($id);
$album->delete();
return redirect(route('albums.index'));
}
/**
* @param $id
* @return Album
*/
public static function loadAlbum($id)
private function loadAlbum($id)
{
$album = Album::where('id', intval($id))->first();
if (is_null($album))
@ -170,25 +170,4 @@ class AlbumController extends Controller
return $album;
}
/**
* @param $id
* @param $albumId
* @return Upload|null
*/
private static function loadUpload($id, $albumId)
{
$upload = Upload::where([
'id' => intval($id),
'album_id' => intval($albumId)
])->first();
if (is_null($upload))
{
App::abort(404);
return null;
}
return $upload;
}
}

View File

@ -3,8 +3,13 @@
namespace App\Http\Controllers\Admin;
use App\Album;
use App\AlbumSources\IAlbumSource;
use App\Facade\Image;
use App\Facade\Theme;
use App\Helpers\ImageHelper;
use App\Helpers\MiscHelper;
use App\Photo;
use App\Services\PhotoService;
use App\Upload;
use App\UploadPhoto;
use Illuminate\Http\Request;
@ -16,6 +21,38 @@ use Symfony\Component\HttpFoundation\File\File;
class PhotoController extends Controller
{
public function analyse($photoId)
{
$this->authorize('admin-access');
/** @var Photo $photo */
$photo = Photo::where('id', intval($photoId))->first();
if (is_null($photo))
{
App::abort(404);
return null;
}
$result = ['is_successful' => false, 'message' => ''];
try
{
$photoService = new PhotoService($photo);
$photoService->analyse();
$result['is_successful'] = true;
}
catch (\Exception $ex)
{
$result['is_successful'] = false;
$result['message'] = $ex->getMessage();
$photo->delete();
}
return response()->json($result);
}
/**
* Display a listing of the resource.
*
@ -36,6 +73,27 @@ class PhotoController extends Controller
//
}
public function rotate($photoId, $angle)
{
$this->authorize('admin-access');
$photo = Photo::where('id', intval($photoId))->first();
if (is_null($photo))
{
App::abort(404);
return null;
}
if ($angle != 90 && $angle != 180 && $angle != 270)
{
App::aport(400);
return null;
}
$photoService = new PhotoService($photo);
$photoService->rotate($angle);
}
/**
* Store a newly created resource in storage.
*
@ -49,22 +107,14 @@ class PhotoController extends Controller
$photoFiles = $request->files->get('photo');
// Load the linked album
$album = AlbumController::loadAlbum($request->get('album_id'));
$upload = new Upload();
$upload->album_id = $album->id;
$upload->is_completed = false;
$upload->is_processing = false;
$upload->is_ready = false;
$upload->number_photos = 0;
$upload->save();
$album = $this->loadAlbum($request->get('album_id'));
foreach ($photoFiles as $photoFile)
{
$photoFile = UploadedFile::createFromBase($photoFile);
/** @var File $savedFile */
$savedFile = $album->getAlbumSource()->saveUploadedPhoto($album, $photoFile);
$savedFile = $album->getAlbumSource()->saveUploadedPhoto($photoFile);
$photo = new Photo();
$photo->album_id = $album->id;
@ -73,22 +123,12 @@ class PhotoController extends Controller
$photo->storage_file_name = $savedFile->getFilename();
$photo->mime_type = $savedFile->getMimeType();
$photo->file_size = $savedFile->getSize();
$photo->is_analysed = false;
$photo->save();
$upload->number_photos++;
$uploadPhoto = new UploadPhoto();
$uploadPhoto->upload_id = $upload->id;
$uploadPhoto->photo_id = $photo->id;
$uploadPhoto->save();
}
$upload->is_ready = true;
$upload->save();
return redirect(route('albums.monitorUpload', [
'id' => $album->id,
'upload_id' => $upload->id
return redirect(route('albums.analyse', [
'id' => $album->id
]));
}
@ -99,7 +139,7 @@ class PhotoController extends Controller
$archiveFile = UploadedFile::createFromBase($request->files->get('archive'));
// Load the linked album
$album = AlbumController::loadAlbum($request->get('album_id'));
$album = $this->loadAlbum($request->get('album_id'));
// Create a temporary folder to hold the extracted files
$tempFolder = sprintf('%s/btw_upload_%s', env('TEMP_FOLDER', '/tmp'), MiscHelper::randomString());
@ -120,14 +160,6 @@ class PhotoController extends Controller
return redirect(route('albums.show', ['id' => $album->id]));
}
$upload = new Upload();
$upload->album_id = $album->id;
$upload->is_completed = false;
$upload->is_processing = false;
$upload->is_ready = false;
$upload->number_photos = 0;
$upload->save();
$di = new \RecursiveDirectoryIterator($tempFolder, \RecursiveDirectoryIterator::SKIP_DOTS);
$recursive = new \RecursiveIteratorIterator($di);
@ -136,13 +168,26 @@ class PhotoController extends Controller
{
if ($fileInfo->isDir())
{
if ($fileInfo->getFilename() == '__MACOSX')
{
@rmdir($fileInfo->getPathname());
}
continue;
}
$result = getimagesize($fileInfo->getPathname());
if ($result === false)
{
// Not an image file - skip
@unlink($fileInfo->getPathname());
continue;
}
$photoFile = new File($fileInfo->getPathname());
/** @var File $savedFile */
$savedFile = $album->getAlbumSource()->saveUploadedPhoto($album, $photoFile);
$savedFile = $album->getAlbumSource()->saveUploadedPhoto($photoFile);
$photo = new Photo();
$photo->album_id = $album->id;
@ -151,22 +196,14 @@ class PhotoController extends Controller
$photo->storage_file_name = $savedFile->getFilename();
$photo->mime_type = $savedFile->getMimeType();
$photo->file_size = $savedFile->getSize();
$photo->is_analysed = false;
$photo->save();
$upload->number_photos++;
$uploadPhoto = new UploadPhoto();
$uploadPhoto->upload_id = $upload->id;
$uploadPhoto->photo_id = $photo->id;
$uploadPhoto->save();
}
$upload->is_ready = true;
$upload->save();
@rmdir($tempFolder);
return redirect(route('albums.monitorUpload', [
'id' => $album->id,
'upload_id' => $upload->id
return redirect(route('albums.analyse', [
'id' => $album->id
]));
}
@ -243,4 +280,20 @@ class PhotoController extends Controller
{
//
}
/**
* @param $id
* @return Album
*/
private function loadAlbum($id)
{
$album = Album::where('id', intval($id))->first();
if (is_null($album))
{
App::abort(404);
return null;
}
return $album;
}
}

View File

@ -8,13 +8,16 @@ use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AlbumController extends Controller
{
public function index($albumUrlAlias)
{
$album = AlbumController::loadAlbum($albumUrlAlias);
$photos = $album->photos()->paginate(UserConfig::get('items_per_page'));
$photos = $album->photos()
->orderBy(DB::raw('COALESCE(taken_at, created_at)'))
->paginate(UserConfig::get('items_per_page_admin'));
return Theme::render('gallery.album', [
'album' => $album,

View File

@ -20,7 +20,7 @@ class PhotoController extends Controller
$thumbnail = $request->get('t', $albumSource->getOriginalsFolder());
$photo = PhotoController::loadPhotoByAlbumAndFilename($album, $photoFilename);
return response()->file($albumSource->getPathToPhoto($album, $photo, $thumbnail));
return response()->file($albumSource->getPathToPhoto($photo, $thumbnail));
}
public function show($albumUrlAlias, $photoFilename)

View File

@ -28,7 +28,8 @@ class Photo extends Model
'camera_model',
'camera_software',
'width',
'height'
'height',
'is_analysed'
];
/**
@ -46,10 +47,7 @@ class Photo extends Model
public function thumbnailUrl($thumbnailName = null)
{
/** @var Album $album */
$album = $this->album;
return $album->getAlbumSource()->getUrlToPhoto($album, $this, $thumbnailName);
return $this->album->getAlbumSource()->getUrlToPhoto($this, $thumbnailName);
}
public function url()

View File

@ -0,0 +1,206 @@
<?php
namespace App\Services;
use App\Album;
use App\AlbumSources\IAlbumSource;
use App\Helpers\ImageHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
class PhotoService
{
const METADATA_VERSION = 1;
/**
* @var Album
*/
private $album;
/**
* @var IAlbumSource
*/
private $albumSource;
/**
* @var ImageHelper
*/
private $imageHelper;
/**
* @var Photo
*/
private $photo;
/**
* @var ThemeHelper
*/
private $themeHelper;
public function __construct(Photo $photo)
{
$this->photo = $photo;
$this->album = $photo->album;
$this->albumSource = $this->album->getAlbumSource();
$this->imageHelper = new ImageHelper();
$this->themeHelper = new ThemeHelper();
}
public function analyse()
{
/** @var Album $album */
$album = $this->photo->album;
$albumSource = $album->getAlbumSource();
$photoFile = $albumSource->getPathToPhoto($this->photo);
$imageInfo = null;
$originalPhotoResource = $this->imageHelper->openImage($photoFile, $imageInfo);
if ($originalPhotoResource === false)
{
throw new \Exception(sprintf('The image "%s" does not appear to be a valid image, or cannot be read', pathinfo($photoFile, PATHINFO_FILENAME)));
}
$this->photo->width = $imageInfo[0];
$this->photo->height = $imageInfo[1];
$this->photo->mime_type = $imageInfo['mime'];
// Read the Exif data
$exifData = @exif_read_data($photoFile);
$isExifDataFound = ($exifData !== false && is_array($exifData));
$angleToRotate = 0;
// If Exif data contains an Orientation, ensure we rotate the original image as such
if ($isExifDataFound && isset($exifData['Orientation']))
{
switch ($exifData['Orientation'])
{
case 3:
$angleToRotate = 180;
break;
case 6:
$angleToRotate = 270;
break;
case 8:
$angleToRotate = 90;
break;
}
if ($angleToRotate > 0)
{
$originalPhotoResource = $this->imageHelper->rotateImage($originalPhotoResource, $angleToRotate);
if ($angleToRotate == 90 || $angleToRotate == 270)
{
$this->photo->width = $imageInfo[1];
$this->photo->height = $imageInfo[0];
}
$this->imageHelper->saveImage($originalPhotoResource, $photoFile, $imageInfo);
}
}
if ($isExifDataFound)
{
$this->photo->metadata_version = self::METADATA_VERSION;
$this->photo->taken_at = $this->metadataDateTime($exifData);
$this->photo->camera_make = $this->metadataCameraMake($exifData);
$this->photo->camera_model = $this->metadataCameraModel($exifData);
$this->photo->camera_software = $this->metadataCameraSoftware($exifData);
}
$this->photo->is_analysed = true;
$this->photo->save();
$this->regenerateThumbnails($originalPhotoResource);
}
public function regenerateThumbnails($originalPhotoResource = null)
{
if (is_null($originalPhotoResource))
{
$imageInfo = null;
$originalPhotoResource = $this->imageHelper->openImage($this->albumSource->getPathToPhoto($this->photo), $imageInfo);
}
// Generate and save thumbnails
$themeInfo = $this->themeHelper->info();
$thumbnailsRequired = $themeInfo['thumbnails'];
/** @var mixed $thumbnail */
foreach ($thumbnailsRequired as $thumbnail)
{
$generatedThumbnailPath = $this->imageHelper->generateThumbnail($originalPhotoResource, $this->photo, $thumbnail);
$this->albumSource->saveThumbnail($this->photo, $thumbnail, $generatedThumbnailPath);
}
}
public function rotate($angle)
{
$imageInfo = array();
$photoPath = $this->albumSource->getPathToPhoto($this->photo);
$originalPhotoImage = $this->imageHelper->openImage($photoPath, $imageInfo);
$originalPhotoImage = $this->imageHelper->rotateImage($originalPhotoImage, intval($angle));
$this->imageHelper->saveImage($originalPhotoImage, $photoPath, $imageInfo);
if ($angle == 90 || $angle == 270)
{
$width = $this->photo->width;
$this->photo->width = $this->photo->height;
$this->photo->height = $width;
}
$this->regenerateThumbnails($originalPhotoImage);
$this->photo->save();
}
private function metadataCameraMake(array $exifData)
{
if (isset($exifData['Make']))
{
return $exifData['Make'];
}
return null;
}
private function metadataCameraModel(array $exifData)
{
if (isset($exifData['Model']))
{
return $exifData['Model'];
}
return null;
}
private function metadataCameraSoftware(array $exifData)
{
if (isset($exifData['Software']))
{
return $exifData['Software'];
}
return null;
}
private function metadataDateTime(array $exifData)
{
$dateTime = null;
if (isset($exifData['DateTime']))
{
$dateTime = $exifData['DateTime'];
}
if (!is_null($dateTime))
{
$dateTime = preg_replace('/^([\d]{4}):([\d]{2}):([\d]{2})/', '$1-$2-$3', $dateTime);
}
return $dateTime;
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
class Upload extends Model
{
use Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'is_completed', 'is_processing', 'number_photos', 'number_successful', 'number_failed'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
];
public function album()
{
return $this->belongsTo(Album::class);
}
public function uploadPhotos()
{
return $this->hasMany(UploadPhoto::class);
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
class UploadPhoto extends Model
{
use Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'upload_id', 'photo_id'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
];
public function photo()
{
return $this->belongsTo(Photo::class);
}
}

View File

@ -229,8 +229,9 @@ return [
// Additional aliases added by AH
'Form' => Collective\Html\FormFacade::class,
'Html' => Collective\Html\HtmlFacade::class,
'Theme' => App\Facade\Theme::class,
'UserConfig' => App\Facade\UserConfig::class
'Image' => \App\Facade\Image::class,
'Theme' => \App\Facade\Theme::class,
'UserConfig' => \App\Facade\UserConfig::class
],
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemovePhotoRotation extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('photos', function (Blueprint $table) {
$table->dropColumn('rotation');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('photos', function (Blueprint $table) {
$table->integer('rotation')->nullable();
});
}
}

View File

@ -0,0 +1,58 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemoveUploadTables extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::dropIfExists('upload_photos');
Schema::dropIfExists('uploads');
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::create('uploads', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('album_id');
$table->boolean('is_completed');
$table->boolean('is_processing');
$table->integer('number_photos')->default(0);
$table->integer('number_successful')->default(0);
$table->integer('number_failed')->default(0);
$table->boolean('is_ready');
$table->timestamps();
$table->foreign('album_id')
->references('id')->on('albums')
->onDelete('cascade');
});
Schema::create('upload_photos', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('upload_id');
$table->unsignedBigInteger('photo_id');
$table->boolean('is_ready');
$table->timestamps();
$table->foreign('upload_id')
->references('id')->on('uploads')
->onDelete('cascade');
$table->foreign('photo_id')
->references('id')->on('photos')
->onDelete('cascade');
});
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddPhotoIsAnalysedColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('photos', function (Blueprint $table) {
$table->boolean('is_analysed');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('photos', function (Blueprint $table) {
$table->dropColumn('is_analysed');
});
}
}

View File

@ -0,0 +1,126 @@
@extends('themes.base.layout')
@section('title', 'Analysing...')
@section('content')
<div class="container" style="margin-top: 40px;">
<div class="row">
<div class="col-xs-12 col-sm-8 col-sm-offset-2">
<div class="panel panel-default" id="status-panel">
<div class="panel-heading">Analysing...</div>
<div class="panel-body">
<p>Your uploaded photos are now being analysed.</p>
<div id="progress-bar-container">
<div class="progress"></div>
</div>
<div id="file-list" style="margin-top: 20px;">
@foreach ($photos as $photo)
<p data-photo-id="{{ $photo->id }}">{{ $photo->name }} ... <i class="fa fa-fw"></i></p>
@endforeach
</div>
</div>
</div>
<div class="panel panel-default" id="complete-panel" style="display: none;">
<div class="panel-heading">Upload completed</div>
<div class="panel-body">
<p>Your upload has completed.</p>
<div class="btn-toolbar btn-group-sm pull-right">
<a class="btn btn-default" href="{{ $album->url() }}">View album</a>
<a class="btn btn-primary" href="{{ route('albums.show', ['id' => $album->id]) }}">Back to album settings</a>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
var number_successful = 0;
var number_error = 0;
var number_total = 0;
function redrawProgressBar()
{
var successPercentage = (number_successful / number_total) * 100;
var failedPercentage = (number_error / number_total) * 100;
{{-- Render a Bootstrap-3 compatible progress bar --}}
var progressBar = $('<div/>').addClass('progress');
{{-- Successful --}}
var progressBarSuccess = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-success')
.css('width', parseInt(successPercentage) + '%')
.appendTo(progressBar);
var progressBarSuccessSpan = $('<span/>').addClass('sr-only').html(parseInt(successPercentage) + '% successful').appendTo(progressBarSuccess);
{{-- Failed --}}
var progressBarError = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-warning')
.css('width', parseInt(failedPercentage) + '%')
.appendTo(progressBar);
var progressBarErrorSpan = $('<span/>').addClass('sr-only').html(parseInt(failedPercentage) + '% failed').appendTo(progressBarError);
{{-- Add to DOM --}}
$('#progress-bar-container').html(progressBar[0].outerHTML);
}
$(document).ready(function() {
number_total = $('#file-list p').length;
if (number_total == 0) {
$('#status-panel').hide();
$('#complete-panel').show();
}
else
{
$('#file-list p').each(function (index, element) {
var photo_id = $(element).data('photo-id');
var url = '{{ route('photos.analyse', ['id' => 0]) }}';
url = url.replace(/0$/, photo_id);
$.ajax(
url,
{
complete: function () {
redrawProgressBar();
if (number_successful + number_error >= number_total) {
$('#status-panel').hide();
$('#complete-panel').show();
}
},
dataType: 'json',
error: function (xhr, textStatus, errorThrown) {
$('i', '#file-list p[data-photo-id=' + photo_id + ']')
.addClass('text-danger')
.addClass('fa-times');
number_error++;
},
method: 'POST',
success: function (data) {
if (data.is_successful) {
$('i', '#file-list p[data-photo-id=' + photo_id + ']')
.addClass('text-success')
.addClass('fa-check');
number_successful++;
}
else {
$('i', '#file-list p[data-photo-id=' + photo_id + ']')
.addClass('text-danger')
.addClass('fa-times');
number_error++;
}
}
}
);
});
}
});
</script>
@endpush

View File

@ -1,84 +0,0 @@
@extends('themes.base.layout')
@section('title', 'Processing...')
@section('content')
<div class="container" style="margin-top: 40px;">
<div class="row">
<div class="col-xs-12 col-sm-8 col-sm-offset-2">
<div class="panel panel-default" id="status-panel">
<div class="panel-heading">Processing...</div>
<div class="panel-body">
<p>Your upload is now being processed.</p>
<div id="progress-bar-container">
<div class="progress"></div>
</div>
</div>
</div>
<div class="panel panel-default" id="complete-panel" style="display: none;">
<div class="panel-heading">Upload completed</div>
<div class="panel-body">
<p>Your upload has completed.</p>
<div class="btn-toolbar btn-group-sm pull-right">
<a class="btn btn-default" href="{{ $album->url() }}">View album</a>
<a class="btn btn-primary" href="{{ route('albums.show', ['id' => $album->id]) }}">Back to album settings</a>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
var refreshInterval = null;
function refreshStatus()
{
$.get('{{ route('albums.monitorUploadJson', ['id' => $album->id, 'upload_id' => $upload->id]) }}', function(data)
{
var total = data.number_photos;
if (data.is_completed)
{
// Stop the refresh
window.clearInterval(refreshInterval);
// Display the complete box
$('#status-panel').hide();
$('#complete-panel').show();
}
var successPercentage = (data.number_successful / data.number_photos) * 100;
var failedPercentage = (data.number_failed / data.number_photos) * 100;
{{-- Render a Bootstrap-3 compatible progress bar --}}
var progressBar = $('<div/>').addClass('progress');
{{-- Successful --}}
var progressBarSuccess = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-success')
.css('width', parseInt(successPercentage) + '%')
.appendTo(progressBar);
var progressBarSuccessSpan = $('<span/>').addClass('sr-only').html(parseInt(successPercentage) + '% successful').appendTo(progressBarSuccess);
{{-- Failed --}}
var progressBarError = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-warning')
.css('width', parseInt(failedPercentage) + '%')
.appendTo(progressBar);
var progressBarErrorSpan = $('<span/>').addClass('sr-only').html(parseInt(failedPercentage) + '% failed').appendTo(progressBarError);
{{-- Add to DOM --}}
$('#progress-bar-container').html(progressBar[0].outerHTML);
});
}
$(document).ready(function() {
refreshInterval = window.setInterval(refreshStatus, 1000);
});
</script>
@endpush

View File

@ -93,10 +93,41 @@
@push('scripts')
<script type="text/javascript">
function rotatePhoto(photo_id, angle, parent)
{
var url = '{{ route('photos.rotate', ['id' => 0, 'angle' => 1]) }}';
url = url.replace('/0/', '/' + photo_id + '/');
url = url.replace(/\/1$/, '/' + angle);
$.post(url, function()
{
var image = $('img.photo-thumbnail', parent);
var originalUrl = image.data('original-src');
image.attr('src', originalUrl + "&_=" + new Date().getTime());
});
}
$(document).ready(function() {
$('#upload-button').click(function() {
$('.nav-tabs a[href="#upload-tab"]').tab('show');
});
$('a.rotate-photo-left').click(function() {
var parent = $(this).parents('.photo');
var photo_id = $(parent).data('photo-id');
rotatePhoto(photo_id, 90, parent);
$(this).dropdown('toggle');
return false;
});
$('a.rotate-photo-right').click(function() {
var parent = $(this).parents('.photo');
var photo_id = $(parent).data('photo-id');
rotatePhoto(photo_id, 270, parent);
$(this).dropdown('toggle');
return false;
});
})
</script>
@endpush

View File

@ -9,6 +9,8 @@
<title>@yield('title') | {{ UserConfig::get('app_name') }}</title>
<base href="{{ url('/') }}">
<meta name="csrf-token" content="{{ csrf_token() }}">
{{-- Cannot use $theme_url here: if a theme uses the base layout, it would also have to provide all these dependencies! --}}
{{-- As these files are shipped with core (not a theme) use the main app.version instead of the current theme's version --}}
<link href="themes/base/bootstrap/css/bootstrap.min.css?v={{ urlencode(config('app.version')) }}" rel="stylesheet">
@ -59,6 +61,13 @@
<script src="themes/base/js/jquery.min.js?v={{ urlencode(config('app.version')) }}"></script>
<script src="themes/base/bootstrap/js/bootstrap.min.js?v={{ urlencode(config('app.version')) }}"></script>
<script src="themes/base/js/app.js?v={{ urlencode(config('app.version')) }}"></script>
<script type="text/javascript">
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
</script>
@stack('scripts')
</body>
</html>

View File

@ -1,10 +1,21 @@
@php ($field_prefix = sprintf('photo[%d]', $photo->id))
<hr/>
<div class="photo row">
<div class="photo row" data-photo-id="{{ $photo->id }}" style="position: relative;">
<div class="col-xs-12 col-sm-2 text-center">
<a href="{{ $photo->thumbnailUrl() }}" target="_blank">
<img src="{{ $photo->thumbnailUrl('admin-preview') }}" style="max-width: 100%;"/>
</a>
<img class="photo-thumbnail" src="{{ $photo->thumbnailUrl('admin-preview') }}" data-original-src="{{ $photo->thumbnailUrl('admin-preview') }}" style="max-width: 100%;"/>
</a><br/>
<div class="btn-toolbar" role="toolbar" style="margin-top: 5px;">
<div class="btn-group" role="group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-fw fa-rotate-right"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#" class="rotate-photo-left"><i class="fa fa-fw fa-rotate-left"></i> Rotate left</a></li>
<li><a href="#" class="rotate-photo-right"><i class="fa fa-fw fa-rotate-right"></i> Rotate right</a></li>
</ul>
</div>
</div>
</div>
<div class="col-xs-12 col-sm-10">
<div class="form-group">

View File

@ -21,12 +21,13 @@ Route::group(['prefix' => 'admin'], function () {
Route::get('settings', 'Admin\DefaultController@settings')->name('admin.settings');
// Album management
Route::get('albums/{id}/analyse', 'Admin\AlbumController@analyse')->name('albums.analyse');
Route::get('albums/{id}/delete', 'Admin\AlbumController@delete')->name('albums.delete');
Route::get('albums/{id}/monitor/{uploadId}.json', 'Admin\AlbumController@monitorUploadJson')->name('albums.monitorUploadJson');
Route::get('albums/{id}/monitor/{uploadId}', 'Admin\AlbumController@monitorUpload')->name('albums.monitorUpload');
Route::resource('albums', 'Admin\AlbumController');
// Photo management
Route::post('photos/analyse/{id}', 'Admin\PhotoController@analyse')->name('photos.analyse');
Route::post('photos/rotate/{photoId}/{angle}', 'Admin\PhotoController@rotate')->name('photos.rotate');
Route::post('photos/store-bulk', 'Admin\PhotoController@storeBulk')->name('photos.storeBulk');
Route::put('photos/update-bulk/{albumId}', 'Admin\PhotoController@updateBulk')->name('photos.updateBulk');
Route::resource('photos', 'Admin\PhotoController');