Merge branch 'master' into stable24
commit
2771531590
|
@ -8,7 +8,12 @@ This file is manually updated. Please file an issue if something is missing.
|
|||
- **Massively improved video performance**
|
||||
- Memories now comes with a dedicated transcoding server with HLS support.
|
||||
- Read the documentation [here](https://github.com/pulsejet/memories/wiki/Configuration#video-transcoding) carefully for more details.
|
||||
- **Feature**: show EXIF metadata in sidebar ([#68](https://github.com/pulsejet/memories/issues/68))
|
||||
- **Feature**: Show EXIF metadata in sidebar ([#68](https://github.com/pulsejet/memories/issues/68))
|
||||
- **Feature**: Show duration on video tiles
|
||||
- **Feature**: Allow editing all image formats (HEIC etc.)
|
||||
- Fix stretched images in viewer ([#176](https://github.com/pulsejet/memories/issues/176))
|
||||
- Restore metadata after image edit ([#174](https://github.com/pulsejet/memories/issues/174))
|
||||
- Fix loss of resolution after image edit
|
||||
|
||||
## v4.6.1, v3.6.1 (2022-11-07)
|
||||
|
||||
|
|
|
@ -52,12 +52,13 @@ return [
|
|||
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
|
||||
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'Image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'],
|
||||
['name' => 'Image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
|
||||
['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'],
|
||||
['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PATCH'],
|
||||
['name' => 'Image#jpeg', 'url' => '/api/image/jpeg/{id}', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
|
||||
|
||||
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{fileid}/{profile}', 'verb' => 'GET'],
|
||||
['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'],
|
||||
|
||||
// Config API
|
||||
['name' => 'Other#setUserConfig', 'url' => '/api/config/{key}', 'verb' => 'PUT'],
|
||||
|
|
|
@ -264,6 +264,14 @@ class Index extends Command
|
|||
return;
|
||||
}
|
||||
|
||||
// skip 'IMDB' in path
|
||||
if (false !== strpos($folderPath, 'IMDB')) {
|
||||
$this->output->writeln('Skipping folder '.$folderPath.' because of IMDB');
|
||||
$this->previousLineLength = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$nodes = $folder->getDirectoryListing();
|
||||
|
||||
foreach ($nodes as &$node) {
|
||||
|
|
|
@ -82,34 +82,33 @@ class VideoSetup extends Command
|
|||
return $this->suggestDisable($output);
|
||||
}
|
||||
|
||||
// Check go-transcode binary
|
||||
$output->writeln('Checking for go-transcode binary');
|
||||
// Check go-vod binary
|
||||
$output->writeln('Checking for go-vod binary');
|
||||
|
||||
// Detect architecture
|
||||
$arch = \OCA\Memories\Util::getArch();
|
||||
$libc = \OCA\Memories\Util::getLibc();
|
||||
|
||||
if (!$arch || !$libc) {
|
||||
$output->writeln('<error>Compatible go-transcode binary not found</error>');
|
||||
$this->suggestGoTranscode($output);
|
||||
if (!$arch) {
|
||||
$output->writeln('<error>Compatible go-vod binary not found</error>');
|
||||
$this->suggestGoVod($output);
|
||||
|
||||
return $this->suggestDisable($output);
|
||||
}
|
||||
|
||||
$goTranscodePath = realpath(__DIR__."/../../exiftool-bin/go-transcode-{$arch}-{$libc}");
|
||||
$output->writeln("Trying go-transcode from {$goTranscodePath}");
|
||||
chmod($goTranscodePath, 0755);
|
||||
$goVodPath = realpath(__DIR__."/../../exiftool-bin/go-vod-{$arch}");
|
||||
$output->writeln("Trying go-vod from {$goVodPath}");
|
||||
chmod($goVodPath, 0755);
|
||||
|
||||
$goTranscode = shell_exec($goTranscodePath.' --help');
|
||||
if (!$goTranscode || false === strpos($goTranscode, 'Available Commands')) {
|
||||
$output->writeln('<error>go-transcode could not be run</error>');
|
||||
$this->suggestGoTranscode($output);
|
||||
$goVod = shell_exec($goVodPath.' test');
|
||||
if (!$goVod || false === strpos($goVod, 'test successful')) {
|
||||
$output->writeln('<error>go-vod could not be run</error>');
|
||||
$this->suggestGoVod($output);
|
||||
|
||||
return $this->suggestDisable($output);
|
||||
}
|
||||
|
||||
// Go transcode is working. Yay!
|
||||
$output->writeln('go-transcode is installed!');
|
||||
$output->writeln('go-vod is installed!');
|
||||
$output->writeln('');
|
||||
$output->writeln('You can use transcoding and HLS streaming');
|
||||
$output->writeln('This is recommended for better performance, but has implications if');
|
||||
|
@ -127,10 +126,7 @@ class VideoSetup extends Command
|
|||
return 0;
|
||||
}
|
||||
|
||||
$tConfig = realpath(__DIR__.'/../../transcoder.yaml');
|
||||
|
||||
$this->config->setSystemValue('memories.transcoder', $goTranscodePath);
|
||||
$this->config->setSystemValue('memories.transcoder_config', $tConfig);
|
||||
$this->config->setSystemValue('memories.transcoder', $goVodPath);
|
||||
$this->config->setSystemValue('memories.no_transcode', false);
|
||||
$output->writeln('Transcoding and HLS are now enabled!');
|
||||
|
||||
|
@ -153,10 +149,10 @@ class VideoSetup extends Command
|
|||
return 0;
|
||||
}
|
||||
|
||||
protected function suggestGoTranscode(OutputInterface $output): void
|
||||
protected function suggestGoVod(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln('You may build go-transcode from source');
|
||||
$output->writeln('It can be downloaded from https://github.com/pulsejet/go-transcode');
|
||||
$output->writeln('You may build go-vod from source');
|
||||
$output->writeln('It can be downloaded from https://github.com/pulsejet/go-vod');
|
||||
$output->writeln('Once built, point the path to the binary in the config for `memories.transcoder`');
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ use OCP\App\IAppManager;
|
|||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\IConfig;
|
||||
|
@ -133,6 +134,35 @@ class ApiBase extends Controller
|
|||
return $folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file with ID from user's folder.
|
||||
*
|
||||
* @param int $fileId
|
||||
*
|
||||
* @return null|File
|
||||
*/
|
||||
protected function getUserFile(int $id)
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return null;
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById($id);
|
||||
if (0 === \count($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if node is a file
|
||||
if (!$file[0] instanceof File) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $file[0];
|
||||
}
|
||||
|
||||
protected function isRecursive()
|
||||
{
|
||||
return null === $this->request->getParam('folder');
|
||||
|
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace OCA\Memories\Controller;
|
||||
|
||||
use OCA\Memories\AppInfo\Application;
|
||||
use OCA\Memories\Exif;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
|
@ -38,79 +39,94 @@ class ImageController extends ApiBase
|
|||
*/
|
||||
public function info(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
$file = $this->getUserFile((int) $id);
|
||||
if (!$file) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Get the image info
|
||||
$basic = false !== $this->request->getParam('basic', false);
|
||||
$info = $this->timelineQuery->getInfoById($file->getId(), $basic);
|
||||
|
||||
// Get latest exif data if requested
|
||||
if ($this->request->getParam('current', false)) {
|
||||
$info['current'] = Exif::getExifFromFile($file);
|
||||
}
|
||||
|
||||
return new JSONResponse($info, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* Change exif data for one file
|
||||
* Set the exif data for a file.
|
||||
*
|
||||
* @param string fileid
|
||||
*/
|
||||
public function edit(string $id): JSONResponse
|
||||
public function setExif(string $id): JSONResponse
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
return new JSONResponse([], Http::STATUS_PRECONDITION_FAILED);
|
||||
}
|
||||
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
|
||||
|
||||
// Check for permissions and get numeric Id
|
||||
$file = $userFolder->getById((int) $id);
|
||||
if (0 === \count($file)) {
|
||||
$file = $this->getUserFile((int) $id);
|
||||
if (!$file) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
$file = $file[0];
|
||||
|
||||
// Check if user has permissions
|
||||
if (!$file->isUpdateable()) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get new date from body
|
||||
$body = $this->request->getParams();
|
||||
if (!isset($body['date'])) {
|
||||
return new JSONResponse(['message' => 'Missing date'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
// Get original file from body
|
||||
$exif = $this->request->getParam('raw');
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
|
||||
// Make sure the date is valid
|
||||
try {
|
||||
Exif::parseExifDate($body['date']);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update date
|
||||
try {
|
||||
$res = Exif::updateExifDate($file, $body['date']);
|
||||
if (false === $res) {
|
||||
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
Exif::setExif($path, $exif);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Update remote file if not local
|
||||
if (!$file->getStorage()->isLocal()) {
|
||||
$file->putContent(fopen($path, 'r')); // closes the handler
|
||||
}
|
||||
|
||||
// Reprocess the file
|
||||
$this->timelineWrite->processFile($file, true);
|
||||
|
||||
return $this->info($id);
|
||||
return new JSONResponse([], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @NoCSRFRequired
|
||||
*
|
||||
* Get a full resolution JPEG for editing from a file.
|
||||
*/
|
||||
public function jpeg(string $id)
|
||||
{
|
||||
$file = $this->getUserFile((int) $id);
|
||||
if (!$file) {
|
||||
return new JSONResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
// check if valid image
|
||||
$mimetype = $file->getMimeType();
|
||||
if (!\in_array($mimetype, Application::IMAGE_MIMES, true)) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Get the image
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
$image = new \Imagick($path);
|
||||
$image->setImageFormat('jpeg');
|
||||
$image->setImageCompressionQuality(95);
|
||||
$blob = $image->getImageBlob();
|
||||
|
||||
// Return the image
|
||||
$response = new Http\DataDisplayResponse($blob, Http::STATUS_OK, ['Content-Type' => $image->getImageMimeType()]);
|
||||
$response->cacheFor(3600 * 24, false, false);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -103,6 +103,9 @@ class PageController extends Controller
|
|||
$policy->addAllowedScriptDomain('blob:');
|
||||
$policy->addAllowedMediaDomain('blob:');
|
||||
|
||||
// Image editor
|
||||
$policy->addAllowedConnectDomain('data:');
|
||||
|
||||
// Allow nominatim for metadata
|
||||
$policy->addAllowedConnectDomain('nominatim.openstreetmap.org');
|
||||
$policy->addAllowedFrameDomain('www.openstreetmap.org');
|
||||
|
|
|
@ -36,12 +36,9 @@ class VideoController extends ApiBase
|
|||
*
|
||||
* Transcode a video to HLS by proxy
|
||||
*
|
||||
* @param string fileid
|
||||
* @param string video profile
|
||||
*
|
||||
* @return JSONResponse an empty JSONResponse with respective http status code
|
||||
*/
|
||||
public function transcode(string $fileid, string $profile): Http\Response
|
||||
public function transcode(string $client, string $fileid, string $profile): Http\Response
|
||||
{
|
||||
$user = $this->userSession->getUser();
|
||||
if (null === $user) {
|
||||
|
@ -53,6 +50,11 @@ class VideoController extends ApiBase
|
|||
return new JSONResponse(['message' => 'Transcoding disabled'], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Check client identifier is 8 characters or more
|
||||
if (\strlen($client) < 8) {
|
||||
return new JSONResponse(['message' => 'Invalid client identifier'], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get file
|
||||
$files = $this->rootFolder->getUserFolder($user->getUID())->getById($fileid);
|
||||
if (0 === \count($files)) {
|
||||
|
@ -78,14 +80,13 @@ class VideoController extends ApiBase
|
|||
}
|
||||
|
||||
// Make upstream request
|
||||
[$data, $contentType, $returnCode] = $this->getUpstream($path, $profile);
|
||||
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
|
||||
|
||||
// If status code was 0, it's likely the server is down
|
||||
// Make one attempt to start if we can't find the process
|
||||
if (0 === $returnCode) {
|
||||
$transcoder = $this->config->getSystemValue('memories.transcoder', false);
|
||||
$tConfig = $this->config->getSystemValue('memories.transcoder_config', false);
|
||||
if (!$transcoder || !$tConfig) {
|
||||
if (!$transcoder) {
|
||||
return new JSONResponse(['message' => 'Transcoder not configured'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
|
@ -97,15 +98,12 @@ class VideoController extends ApiBase
|
|||
}
|
||||
|
||||
// Check if already running
|
||||
exec('ps a | grep go-transcode | grep -v grep', $procs);
|
||||
if (0 === \count($procs)) {
|
||||
shell_exec("mkdir -p {$tmpDir}/transcoder"); // php func has some weird problems
|
||||
shell_exec("{$env} nohup {$transcoder} serve --config {$tConfig} > {$tmpDir}/transcoder/run.log 2>&1 & > /dev/null");
|
||||
}
|
||||
shell_exec("pkill {$transcoder}");
|
||||
shell_exec("{$env} nohup {$transcoder} > {$tmpDir}/go-vod.log 2>&1 & > /dev/null");
|
||||
|
||||
// wait for 2s and try again
|
||||
sleep(2);
|
||||
[$data, $contentType, $returnCode] = $this->getUpstream($path, $profile);
|
||||
// wait for 1s and try again
|
||||
sleep(1);
|
||||
[$data, $contentType, $returnCode] = $this->getUpstream($client, $path, $profile);
|
||||
}
|
||||
|
||||
// Check data was received
|
||||
|
@ -117,14 +115,15 @@ class VideoController extends ApiBase
|
|||
$response = new DataDisplayResponse($data, Http::STATUS_OK, [
|
||||
'Content-Type' => $contentType,
|
||||
]);
|
||||
$response->cacheFor(3600 * 24, false, false);
|
||||
$response->cacheFor(0, false, false);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function getUpstream($path, $profile)
|
||||
private function getUpstream($client, $path, $profile)
|
||||
{
|
||||
$ch = curl_init("http://localhost:47788/vod/{$path}/{$profile}");
|
||||
$path = rawurlencode($path);
|
||||
$ch = curl_init("http://127.0.0.1:47788/{$client}{$path}/{$profile}");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
curl_setopt($ch, CURLOPT_HEADER, 0);
|
||||
|
|
|
@ -102,7 +102,7 @@ trait TimelineQueryDays
|
|||
// We don't actually use m.datetaken here, but postgres
|
||||
// needs that all fields in ORDER BY are also in SELECT
|
||||
// when using DISTINCT on selected fields
|
||||
$query->select($fileid, 'm.isvideo', 'm.datetaken', 'm.dayid', 'm.w', 'm.h')
|
||||
$query->select($fileid, 'm.isvideo', 'm.video_duration', 'm.datetaken', 'm.dayid', 'm.w', 'm.h')
|
||||
->from('memories', 'm')
|
||||
;
|
||||
|
||||
|
@ -198,11 +198,12 @@ trait TimelineQueryDays
|
|||
// Convert field types
|
||||
$row['fileid'] = (int) $row['fileid'];
|
||||
$row['isvideo'] = (int) $row['isvideo'];
|
||||
$row['video_duration'] = (int) $row['video_duration'];
|
||||
$row['dayid'] = (int) $row['dayid'];
|
||||
$row['w'] = (int) $row['w'];
|
||||
$row['h'] = (int) $row['h'];
|
||||
if (!$row['isvideo']) {
|
||||
unset($row['isvideo']);
|
||||
unset($row['isvideo'], $row['video_duration']);
|
||||
}
|
||||
if ($row['categoryid']) {
|
||||
$row['isfavorite'] = 1;
|
||||
|
|
|
@ -97,9 +97,11 @@ class TimelineWrite
|
|||
$dateTaken = gmdate('Y-m-d H:i:s', $dateTaken);
|
||||
[$w, $h] = Exif::getDimensions($exif);
|
||||
|
||||
// Store raw metadata in the database
|
||||
// We need to remove blacklisted fields to prevent leaking info
|
||||
unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions']);
|
||||
// Video parameters
|
||||
$videoDuration = 0;
|
||||
if ($isvideo) {
|
||||
$videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0);
|
||||
}
|
||||
|
||||
// Truncate any fields >2048 chars
|
||||
foreach ($exif as $key => &$value) {
|
||||
|
@ -124,6 +126,7 @@ class TimelineWrite
|
|||
->set('datetaken', $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR))
|
||||
->set('mtime', $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT))
|
||||
->set('isvideo', $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT))
|
||||
->set('video_duration', $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT))
|
||||
->set('w', $query->createNamedParameter($w, IQueryBuilder::PARAM_INT))
|
||||
->set('h', $query->createNamedParameter($h, IQueryBuilder::PARAM_INT))
|
||||
->set('exif', $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR))
|
||||
|
@ -141,6 +144,7 @@ class TimelineWrite
|
|||
'datetaken' => $query->createNamedParameter($dateTaken, IQueryBuilder::PARAM_STR),
|
||||
'mtime' => $query->createNamedParameter($mtime, IQueryBuilder::PARAM_INT),
|
||||
'isvideo' => $query->createNamedParameter($isvideo, IQueryBuilder::PARAM_INT),
|
||||
'video_duration' => $query->createNamedParameter($videoDuration, IQueryBuilder::PARAM_INT),
|
||||
'w' => $query->createNamedParameter($w, IQueryBuilder::PARAM_INT),
|
||||
'h' => $query->createNamedParameter($h, IQueryBuilder::PARAM_INT),
|
||||
'exif' => $query->createNamedParameter($exifJson, IQueryBuilder::PARAM_STR),
|
||||
|
|
76
lib/Exif.php
76
lib/Exif.php
|
@ -109,7 +109,12 @@ class Exif
|
|||
throw new \Exception('Failed to get local file path');
|
||||
}
|
||||
|
||||
return self::getExifFromLocalPath($path);
|
||||
$exif = self::getExifFromLocalPath($path);
|
||||
|
||||
// We need to remove blacklisted fields to prevent leaking info
|
||||
unset($exif['SourceFile'], $exif['FileName'], $exif['ExifToolVersion'], $exif['Directory'], $exif['FileSize'], $exif['FileModifyDate'], $exif['FileAccessDate'], $exif['FileInodeChangeDate'], $exif['FilePermissions'], $exif['ThumbnailImage']);
|
||||
|
||||
return $exif;
|
||||
}
|
||||
|
||||
/** Get exif data as a JSON object from a local file path */
|
||||
|
@ -225,29 +230,35 @@ class Exif
|
|||
}
|
||||
|
||||
/**
|
||||
* Update exif date using exiftool.
|
||||
* Set exif data using raw json.
|
||||
*
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
* @param string $path to local file
|
||||
* @param array $data exif data
|
||||
*
|
||||
* @throws \Exception on failure
|
||||
*/
|
||||
public static function updateExifDate(File &$file, string $newDate)
|
||||
public static function setExif(string &$path, array &$data)
|
||||
{
|
||||
// Don't want to mess these up, definitely
|
||||
if ($file->isEncrypted()) {
|
||||
throw new \Exception('Cannot update exif date on encrypted files');
|
||||
}
|
||||
$data['SourceFile'] = $path;
|
||||
$raw = json_encode([$data]);
|
||||
$cmd = array_merge(self::getExiftool(), ['-json=-', $path]);
|
||||
$proc = proc_open($cmd, [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
|
||||
// Get path to local (copy) of the file
|
||||
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||
if (!\is_string($path)) {
|
||||
throw new \Exception('Failed to get local file path');
|
||||
}
|
||||
fwrite($pipes[0], $raw);
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Update exif data
|
||||
self::updateExifDateForLocalFile($path, $newDate);
|
||||
$stdout = self::readOrTimeout($pipes[1], 30000);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
if (false !== strpos($stdout, 'error')) {
|
||||
error_log("Exiftool error: {$stdout}");
|
||||
|
||||
// Update remote file if not local
|
||||
if (!$file->getStorage()->isLocal()) {
|
||||
$file->putContent(fopen($path, 'r')); // closes the handler
|
||||
throw new \Exception('Could not set exif data: '.$stdout);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -357,7 +368,7 @@ class Exif
|
|||
|
||||
private static function getExifFromLocalPathWithStaticProc(string &$path)
|
||||
{
|
||||
fwrite(self::$staticPipes[0], "{$path}\n-json\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
|
||||
fwrite(self::$staticPipes[0], "{$path}\n-json\n-b\n-api\nQuickTimeUTC=1\n-n\n-execute\n");
|
||||
fflush(self::$staticPipes[0]);
|
||||
|
||||
$readyToken = "\n{ready}\n";
|
||||
|
@ -379,7 +390,7 @@ class Exif
|
|||
private static function getExifFromLocalPathWithSeparateProc(string &$path)
|
||||
{
|
||||
$pipes = [];
|
||||
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', $path]), [
|
||||
$proc = proc_open(array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-n', '-json', '-b', $path]), [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
|
@ -410,29 +421,4 @@ class Exif
|
|||
|
||||
return $json[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update exif date using exiftool for a local file.
|
||||
*
|
||||
* @param string $newDate formatted in standard Exif format (YYYY:MM:DD HH:MM:SS)
|
||||
*
|
||||
* @throws \Exception on failure
|
||||
*/
|
||||
private static function updateExifDateForLocalFile(string $path, string $newDate)
|
||||
{
|
||||
$cmd = array_merge(self::getExiftool(), ['-api', 'QuickTimeUTC=1', '-overwrite_original', '-DateTimeOriginal='.$newDate, $path]);
|
||||
$proc = proc_open($cmd, [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
], $pipes);
|
||||
$stdout = self::readOrTimeout($pipes[1], 300000);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
proc_terminate($proc);
|
||||
if (false !== strpos($stdout, 'error')) {
|
||||
error_log("Exiftool error: {$stdout}");
|
||||
|
||||
throw new \Exception('Could not update exif date: '.$stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2022 Your name <your@email.com>
|
||||
* @author Your name <your@email.com>
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace OCA\Memories\Migration;
|
||||
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Auto-generated migration step: Please modify to your needs!
|
||||
*/
|
||||
class Version400700Date20221110030909 extends SimpleMigrationStep
|
||||
{
|
||||
/**
|
||||
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
|
||||
*/
|
||||
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
|
||||
{
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
$table = $schema->getTable('memories');
|
||||
|
||||
$table->addColumn('video_duration', Types::INTEGER, [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void
|
||||
{
|
||||
}
|
||||
}
|
|
@ -17,11 +17,9 @@ mv "exiftool-$exifver" exiftool
|
|||
rm -rf *.zip exiftool/t exiftool/html
|
||||
chmod 755 exiftool/exiftool
|
||||
|
||||
gotranscode="v0.0.2"
|
||||
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-amd64-musl"
|
||||
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-amd64-glibc"
|
||||
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-aarch64-musl"
|
||||
wget -q "https://github.com/pulsejet/go-transcode/releases/download/$gotranscode/go-transcode-aarch64-glibc"
|
||||
chmod 755 go-transcode-*
|
||||
govod="0.0.3"
|
||||
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-amd64"
|
||||
wget -q "https://github.com/pulsejet/go-vod/releases/download/$govod/go-vod-aarch64"
|
||||
chmod 755 go-vod-*
|
||||
|
||||
cd ..
|
||||
|
|
|
@ -9,7 +9,9 @@ import GlobalMixin from "../mixins/GlobalMixin";
|
|||
import { basename, dirname, extname, join } from "path";
|
||||
import { emit } from "@nextcloud/event-bus";
|
||||
import { showError, showSuccess } from "@nextcloud/dialogs";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
import FilerobotImageEditor from "filerobot-image-editor";
|
||||
import { FilerobotImageEditorConfig } from "react-filerobot-image-editor";
|
||||
|
||||
|
@ -25,11 +27,22 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
@Prop() mime: string;
|
||||
@Prop() src: string;
|
||||
|
||||
private exif: any = null;
|
||||
|
||||
private imageEditor: FilerobotImageEditor = null;
|
||||
|
||||
get config(): FilerobotImageEditorConfig & { theme: any } {
|
||||
let src: string;
|
||||
if (["image/png", "image/jpeg", "image/webp"].includes(this.mime)) {
|
||||
src = this.src;
|
||||
} else {
|
||||
src = generateUrl("/apps/memories/api/image/jpeg/{fileid}", {
|
||||
fileid: this.fileid,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: this.src,
|
||||
source: src,
|
||||
|
||||
defaultSavedImageName: this.defaultSavedImageName,
|
||||
defaultSavedImageType: this.defaultSavedImageType,
|
||||
|
@ -84,8 +97,8 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
},
|
||||
},
|
||||
|
||||
savingPixelRatio: 1,
|
||||
previewPixelRatio: 1,
|
||||
savingPixelRatio: 8,
|
||||
previewPixelRatio: window.devicePixelRatio,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -117,14 +130,32 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
};
|
||||
}
|
||||
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.imageEditor = new FilerobotImageEditor(
|
||||
<any>this.$refs.editor,
|
||||
<any>this.config
|
||||
);
|
||||
this.imageEditor.render();
|
||||
window.addEventListener("keydown", this.handleKeydown, true);
|
||||
window.addEventListener("DOMNodeInserted", this.handleSfxModal);
|
||||
|
||||
// Get latest exif data
|
||||
try {
|
||||
const res = await axios.get(
|
||||
generateUrl("/apps/memories/api/image/info/{id}?basic=1¤t=1", {
|
||||
id: this.fileid,
|
||||
})
|
||||
);
|
||||
|
||||
this.exif = res.data?.current;
|
||||
if (!this.exif) {
|
||||
throw new Error("No exif data");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(
|
||||
this.t("memories", "Failed to get Exif data. Metadata may be lost!")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
|
@ -147,46 +178,64 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
* User saved the image
|
||||
*
|
||||
* @see https://github.com/scaleflex/filerobot-image-editor#onsave
|
||||
* @param {object} props destructuring object
|
||||
* @param {string} props.fullName the file name
|
||||
* @param {HTMLCanvasElement} props.imageCanvas the image canvas
|
||||
* @param {string} props.mimeType the image mime type
|
||||
* @param {number} props.quality the image saving quality
|
||||
*/
|
||||
async onSave({
|
||||
fullName,
|
||||
imageCanvas,
|
||||
mimeType,
|
||||
quality,
|
||||
imageBase64,
|
||||
}: {
|
||||
fullName?: string;
|
||||
imageCanvas?: HTMLCanvasElement;
|
||||
mimeType?: string;
|
||||
quality?: number;
|
||||
imageBase64?: string;
|
||||
}): Promise<void> {
|
||||
if (!imageBase64) {
|
||||
throw new Error("No image data");
|
||||
}
|
||||
|
||||
const { origin, pathname } = new URL(this.src);
|
||||
const putUrl = origin + join(dirname(pathname), fullName);
|
||||
|
||||
// toBlob is not very smart...
|
||||
mimeType = mimeType.replace("jpg", "jpeg");
|
||||
|
||||
// Sanity check, 0 < quality < 1
|
||||
quality = Math.max(Math.min(quality, 1), 0) || 1;
|
||||
if (
|
||||
!this.exif &&
|
||||
!confirm(this.t("memories", "No Exif data found! Continue?"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await new Promise((resolve: BlobCallback) =>
|
||||
imageCanvas.toBlob(resolve, mimeType, quality)
|
||||
);
|
||||
const blob = await fetch(imageBase64).then((res) => res.blob());
|
||||
const response = await axios.put(putUrl, new File([blob], fullName));
|
||||
const fileid =
|
||||
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null;
|
||||
if (response.status >= 400) {
|
||||
throw new Error("Failed to save image");
|
||||
}
|
||||
|
||||
// Strip old and incorrect exif data
|
||||
const exif = this.exif;
|
||||
delete exif.Orientation;
|
||||
delete exif.Rotation;
|
||||
delete exif.ImageHeight;
|
||||
delete exif.ImageWidth;
|
||||
delete exif.ImageSize;
|
||||
delete exif.ModifyDate;
|
||||
delete exif.ExifImageHeight;
|
||||
delete exif.ExifImageWidth;
|
||||
delete exif.ExifImageSize;
|
||||
|
||||
// Update exif data
|
||||
await axios.patch(
|
||||
generateUrl("/apps/memories/api/image/set-exif/{id}", {
|
||||
id: fileid,
|
||||
}),
|
||||
{
|
||||
raw: exif,
|
||||
}
|
||||
);
|
||||
|
||||
showSuccess(this.t("memories", "Image saved successfully"));
|
||||
if (putUrl !== this.src) {
|
||||
emit("files:file:created", {
|
||||
fileid:
|
||||
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null,
|
||||
});
|
||||
if (fileid !== this.fileid) {
|
||||
emit("files:file:created", { fileid });
|
||||
} else {
|
||||
emit("files:file:updated", { fileid: this.fileid });
|
||||
emit("files:file:updated", { fileid });
|
||||
}
|
||||
this.onClose(undefined, false);
|
||||
} catch (error) {
|
||||
|
@ -247,21 +296,6 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch out for Modal inject in document root
|
||||
* That way we can adjust the focusTrap
|
||||
*
|
||||
* @param {Event} event Dom insertion event
|
||||
*/
|
||||
handleSfxModal(event) {
|
||||
if (
|
||||
event.target?.classList &&
|
||||
event.target.classList.contains("SfxModal-Wrapper")
|
||||
) {
|
||||
emit("viewer:trapElements:changed", event.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -590,4 +624,9 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
|
||||
filter: var(--background-invert-if-dark);
|
||||
}
|
||||
|
||||
.FIE_carousel-prev-button,
|
||||
.FIE_carousel-next-button {
|
||||
background: none !important;
|
||||
}
|
||||
</style>
|
|
@ -87,7 +87,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
|
|||
|
||||
let state = this.state;
|
||||
const res = await axios.get<any>(
|
||||
generateUrl("/apps/memories/api/info/{id}", { id: fileInfo.id })
|
||||
generateUrl("/apps/memories/api/image/info/{id}", { id: fileInfo.id })
|
||||
);
|
||||
if (state !== this.state) return;
|
||||
|
||||
|
@ -163,7 +163,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
|
|||
const dt = this.exif["DateTimeOriginal"] || this.exif["CreateDate"];
|
||||
if (!dt) return null;
|
||||
|
||||
const m = moment(dt, "YYYY:MM:DD HH:mm:ss");
|
||||
const m = moment.utc(dt, "YYYY:MM:DD HH:mm:ss");
|
||||
if (!m.isValid()) return null;
|
||||
m.locale(getCanonicalLocale());
|
||||
return m;
|
||||
|
@ -193,6 +193,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
|
|||
const make = this.exif["Make"];
|
||||
const model = this.exif["Model"];
|
||||
if (!make || !model) return null;
|
||||
if (model.startsWith(make)) return model;
|
||||
return `${make} ${model}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import PhotoSwipe from "photoswipe";
|
||||
import { generateUrl } from "@nextcloud/router";
|
||||
import { loadState } from "@nextcloud/initial-state";
|
||||
import axios from "@nextcloud/axios";
|
||||
|
||||
import videojs from "video.js";
|
||||
import "video.js/dist/video-js.min.css";
|
||||
|
@ -13,6 +14,10 @@ const config_noTranscode = loadState(
|
|||
<string>"UNSET"
|
||||
) as boolean | string;
|
||||
|
||||
// Generate client id for this instance
|
||||
// Does not need to be cryptographically secure
|
||||
const clientId = Math.random().toString(36).substring(2, 15).padEnd(12, "0");
|
||||
|
||||
/**
|
||||
* Check if slide has video content
|
||||
*
|
||||
|
@ -62,6 +67,16 @@ class VideoContentSetup {
|
|||
if (isVideoContent(slide) && this.options.preventDragOffset) {
|
||||
const origEvent = e.originalEvent;
|
||||
if (origEvent.type === "pointerdown") {
|
||||
// Check if directly over the videojs control bar
|
||||
const elems = document.elementsFromPoint(
|
||||
origEvent.clientX,
|
||||
origEvent.clientY
|
||||
);
|
||||
if (elems.some((el) => el.classList.contains("vjs-control-bar"))) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const videoHeight = Math.ceil(slide.height * slide.currZoomLevel);
|
||||
const verticalEnding = videoHeight + slide.bounds.center.y;
|
||||
const pointerYPos = origEvent.pageY - pswp.offset.y;
|
||||
|
@ -86,29 +101,28 @@ class VideoContentSetup {
|
|||
const fileid = content.data.photo.fileid;
|
||||
|
||||
// Create hls sources if enabled
|
||||
let hlsSources = [];
|
||||
let sources: any[] = [];
|
||||
const baseUrl = generateUrl(
|
||||
`/apps/memories/api/video/transcode/${fileid}`
|
||||
`/apps/memories/api/video/transcode/${clientId}/${fileid}`
|
||||
);
|
||||
|
||||
if (!config_noTranscode) {
|
||||
hlsSources.push({
|
||||
sources.push({
|
||||
src: `${baseUrl}/index.m3u8`,
|
||||
type: "application/x-mpegURL",
|
||||
});
|
||||
}
|
||||
|
||||
sources.push({
|
||||
src: e.slide.data.src,
|
||||
});
|
||||
|
||||
const overrideNative = !videojs.browser.IS_SAFARI;
|
||||
content.videojs = videojs(content.videoElement, {
|
||||
fluid: true,
|
||||
fill: true,
|
||||
autoplay: true,
|
||||
controls: true,
|
||||
sources: [
|
||||
...hlsSources,
|
||||
{
|
||||
src: e.slide.data.src,
|
||||
},
|
||||
],
|
||||
sources: sources,
|
||||
preload: "metadata",
|
||||
playbackRates: [0.5, 1, 1.5, 2],
|
||||
responsive: true,
|
||||
|
@ -122,16 +136,15 @@ class VideoContentSetup {
|
|||
},
|
||||
});
|
||||
|
||||
content.videojs.on("error", function () {
|
||||
if (this.error().code === 4) {
|
||||
if (this.src().includes("m3u8")) {
|
||||
content.videojs.on("error", () => {
|
||||
if (content.videojs.error().code === 4) {
|
||||
if (content.videojs.src().includes("m3u8")) {
|
||||
// HLS could not be streamed
|
||||
console.error("Video.js: HLS stream could not be opened.");
|
||||
this.src({
|
||||
content.videojs.src({
|
||||
src: e.slide.data.src,
|
||||
});
|
||||
this.options().html5.nativeAudioTracks = true;
|
||||
this.options().html5.nativeVideoTracks = true;
|
||||
this.updateRotation(content, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -150,7 +163,17 @@ class VideoContentSetup {
|
|||
});
|
||||
}, 500);
|
||||
|
||||
globalThis.videojs = content.videojs;
|
||||
// Get correct orientation
|
||||
axios
|
||||
.get<any>(
|
||||
generateUrl("/apps/memories/api/image/info/{id}", {
|
||||
id: content.data.photo.fileid,
|
||||
})
|
||||
)
|
||||
.then((response) => {
|
||||
content.data.exif = response.data?.exif;
|
||||
this.updateRotation(content);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -172,6 +195,28 @@ class VideoContentSetup {
|
|||
});
|
||||
}
|
||||
|
||||
updateRotation(content, val?: number) {
|
||||
const rotation = val ?? Number(content.data.exif?.Rotation);
|
||||
const shouldRotate = content.videojs?.src().includes("m3u8");
|
||||
if (rotation && shouldRotate) {
|
||||
let transform = `rotate(${rotation}deg)`;
|
||||
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
content.videoElement.style.width = content.element.style.height;
|
||||
content.videoElement.style.height = content.element.style.width;
|
||||
|
||||
transform = `translateY(-${content.element.style.width}) ${transform}`;
|
||||
content.videoElement.style.transformOrigin = "bottom left";
|
||||
}
|
||||
|
||||
content.videoElement.style.transform = transform;
|
||||
} else {
|
||||
content.videoElement.style.transform = "none";
|
||||
content.videoElement.style.width = "100%";
|
||||
content.videoElement.style.height = "100%";
|
||||
}
|
||||
}
|
||||
|
||||
onContentDestroy({ content }) {
|
||||
if (isVideoContent(content)) {
|
||||
if (content._videoPosterImg) {
|
||||
|
@ -206,6 +251,8 @@ class VideoContentSetup {
|
|||
placeholderElStyle.width = width + "px";
|
||||
placeholderElStyle.height = height + "px";
|
||||
}
|
||||
|
||||
this.updateRotation(content);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -286,11 +286,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
|||
|
||||
beforeDestroy() {
|
||||
unsubscribe(this.config_eventName, this.softRefresh);
|
||||
unsubscribe("files:file:created", this.softRefresh);
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
created() {
|
||||
subscribe(this.config_eventName, this.softRefresh);
|
||||
subscribe("files:file:created", this.softRefresh);
|
||||
window.addEventListener("resize", this.handleResizeWithDelay);
|
||||
}
|
||||
|
||||
|
|
|
@ -229,7 +229,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
|
||||
/** Event on file changed */
|
||||
handleFileUpdated({ fileid }: { fileid: number }) {
|
||||
console.log("file updated", fileid);
|
||||
if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
|
||||
this.currentPhoto.etag += "_";
|
||||
this.photoswipe.refreshSlideContent(this.currIndex);
|
||||
|
@ -470,7 +469,8 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
// Get the thumbnail image
|
||||
this.photoswipe.addFilter("thumbEl", (thumbEl, data, index) => {
|
||||
const photo = this.list[index - this.globalAnchor];
|
||||
if (!photo || photo.flag & this.c.FLAG_IS_VIDEO) return thumbEl;
|
||||
if (!photo || !photo.w || !photo.h || photo.flag & this.c.FLAG_IS_VIDEO)
|
||||
return thumbEl;
|
||||
return this.thumbElem(photo) || thumbEl;
|
||||
});
|
||||
|
||||
|
@ -586,7 +586,7 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
}
|
||||
|
||||
get canEdit() {
|
||||
return ["image/jpeg", "image/png"].includes(this.currentPhoto?.mimetype);
|
||||
return this.currentPhoto?.mimetype?.startsWith("image/");
|
||||
}
|
||||
|
||||
private openEditor() {
|
||||
|
@ -840,6 +840,10 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
img.pswp__img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pswp__button {
|
||||
color: white;
|
||||
|
||||
|
|
|
@ -16,7 +16,13 @@
|
|||
@click="toggleSelect"
|
||||
/>
|
||||
|
||||
<Video :size="22" v-if="data.flag & c.FLAG_IS_VIDEO" />
|
||||
<div class="video" v-if="data.flag & c.FLAG_IS_VIDEO">
|
||||
<span v-if="data.video_duration" class="time">
|
||||
{{ videoDuration }}
|
||||
</span>
|
||||
<Video :size="22" />
|
||||
</div>
|
||||
|
||||
<Star :size="22" v-if="data.flag & c.FLAG_IS_FAVORITE" />
|
||||
|
||||
<div
|
||||
|
@ -43,14 +49,17 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
|
||||
import { getPreviewUrl } from "../../services/FileUtils";
|
||||
import { IDay, IPhoto } from "../../types";
|
||||
import * as utils from "../../services/Utils";
|
||||
|
||||
import errorsvg from "../../assets/error.svg";
|
||||
import CheckCircle from "vue-material-design-icons/CheckCircle.vue";
|
||||
import Star from "vue-material-design-icons/Star.vue";
|
||||
import Video from "vue-material-design-icons/PlayCircleOutline.vue";
|
||||
import { Component, Emit, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import errorsvg from "../../assets/error.svg";
|
||||
import GlobalMixin from "../../mixins/GlobalMixin";
|
||||
import { getPreviewUrl } from "../../services/FileUtils";
|
||||
import { IDay, IPhoto } from "../../types";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
|
@ -91,6 +100,13 @@ export default class Photo extends Mixins(GlobalMixin) {
|
|||
this.refresh();
|
||||
}
|
||||
|
||||
get videoDuration() {
|
||||
if (this.data.video_duration) {
|
||||
return utils.getDurationStr(this.data.video_duration);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.src = await this.getSrc();
|
||||
}
|
||||
|
@ -274,7 +290,7 @@ $icon-size: $icon-half-size * 2;
|
|||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
.play-circle-outline-icon,
|
||||
.video,
|
||||
.star-icon {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
|
@ -282,12 +298,23 @@ $icon-size: $icon-half-size * 2;
|
|||
transition: transform 0.15s ease;
|
||||
filter: invert(1) brightness(100);
|
||||
}
|
||||
.play-circle-outline-icon {
|
||||
.video {
|
||||
position: absolute;
|
||||
top: var(--icon-dist);
|
||||
right: var(--icon-dist);
|
||||
.p-outer.selected > & {
|
||||
transform: translate(-$icon-size, $icon-size);
|
||||
}
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.time {
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
.star-icon {
|
||||
bottom: var(--icon-dist);
|
||||
|
|
|
@ -143,8 +143,8 @@ import axios from "@nextcloud/axios";
|
|||
import * as utils from "../../services/Utils";
|
||||
import * as dav from "../../services/DavRequests";
|
||||
|
||||
const INFO_API_URL = "/apps/memories/api/info/{id}";
|
||||
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
|
||||
const INFO_API_URL = "/apps/memories/api/image/info/{id}";
|
||||
const EDIT_API_URL = "/apps/memories/api/image/set-exif/{id}";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
|
@ -269,12 +269,11 @@ export default class EditDate extends Mixins(GlobalMixin) {
|
|||
try {
|
||||
this.processing = true;
|
||||
const fileid = this.photos[0].fileid;
|
||||
const res = await axios.patch<any>(
|
||||
generateUrl(EDIT_API_URL, { id: fileid }),
|
||||
{
|
||||
date: this.getExifFormat(this.getDate()),
|
||||
}
|
||||
);
|
||||
await axios.patch<any>(generateUrl(EDIT_API_URL, { id: fileid }), {
|
||||
raw: {
|
||||
DateTimeOriginal: this.getExifFormat(this.getDate()),
|
||||
},
|
||||
});
|
||||
emit("files:file:updated", { fileid });
|
||||
this.emitRefresh(true);
|
||||
this.close();
|
||||
|
|
|
@ -69,6 +69,26 @@ export function getFromNowStr(date: Date) {
|
|||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** Convert number of seconds to time string */
|
||||
export function getDurationStr(sec: number) {
|
||||
let hours = Math.floor(sec / 3600);
|
||||
let minutes: number | string = Math.floor((sec - hours * 3600) / 60);
|
||||
let seconds: number | string = sec - hours * 3600 - minutes * 60;
|
||||
|
||||
if (seconds < 10) {
|
||||
seconds = "0" + seconds;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
if (minutes < 10) {
|
||||
minutes = "0" + minutes;
|
||||
}
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a hash code from a string
|
||||
* @param {String} str The string to hash.
|
||||
|
|
|
@ -77,6 +77,8 @@ export type IPhoto = {
|
|||
|
||||
/** Video flag from server */
|
||||
isvideo?: boolean;
|
||||
/** Video duration from server */
|
||||
video_duration?: number;
|
||||
/** Favorite flag from server */
|
||||
isfavorite?: boolean;
|
||||
/** Is this a folder */
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
# allow debug outputs
|
||||
debug: false
|
||||
|
||||
# mount debug pprof endpoint at /debug/pprof/
|
||||
pprof: false
|
||||
|
||||
# bind server to IP:PORT (use :47788 for all connections)
|
||||
# DO NOT expose this port to the world
|
||||
bind: localhost:47788
|
||||
|
||||
# X-Forwarded-For headers will be used to determine the client IP
|
||||
proxy: true
|
||||
|
||||
# For static files
|
||||
vod:
|
||||
# Root directory for media
|
||||
media-dir: /
|
||||
|
||||
# Temporary transcode output directory, if empty, default tmp folder will be used
|
||||
transcode-dir: /tmp/transcoder/data
|
||||
|
||||
# Available video profiles
|
||||
# Do not change these
|
||||
video-profiles:
|
||||
360p:
|
||||
width: 640 # px
|
||||
height: 360 # px
|
||||
bitrate: 800 # kbps
|
||||
480p:
|
||||
width: 640
|
||||
height: 480
|
||||
bitrate: 1200
|
||||
720p:
|
||||
width: 1280
|
||||
height: 720
|
||||
bitrate: 2800
|
||||
1080p:
|
||||
width: 1920
|
||||
height: 1080
|
||||
bitrate: 5000
|
||||
|
||||
# Use video keyframes as existing reference for chunks split
|
||||
# Using this might cause long probing times in order to get
|
||||
# all keyframes - therefore they should be cached
|
||||
video-keyframes: false
|
||||
|
||||
# Single audio profile used
|
||||
audio-profile:
|
||||
bitrate: 192 # kbps
|
||||
|
||||
# If cache is enabled
|
||||
cache: true
|
||||
# If dir is empty, cache will be stored in the same directory as media source
|
||||
# If not empty, cache files will be saved to specified directory
|
||||
cache-dir: /tmp/transcoder/cache
|
||||
|
||||
# OPTIONAL: Use custom ffmpeg & ffprobe binary paths
|
||||
ffmpeg-binary: ffmpeg
|
||||
ffprobe-binary: ffprobe
|
Loading…
Reference in New Issue