Restore metadata after image edit (fix #174)
parent
6454d439f5
commit
0dc4784f1a
|
@ -11,6 +11,7 @@ This file is manually updated. Please file an issue if something is missing.
|
||||||
- **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**: Show duration on video tiles
|
||||||
- Fix stretched images in viewer ([#176](https://github.com/pulsejet/memories/issues/176))
|
- 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))
|
||||||
|
|
||||||
## v4.6.1, v3.6.1 (2022-11-07)
|
## v4.6.1, v3.6.1 (2022-11-07)
|
||||||
|
|
||||||
|
|
|
@ -52,8 +52,9 @@ return [
|
||||||
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
|
['name' => 'Faces#faces', 'url' => '/api/faces', 'verb' => 'GET'],
|
||||||
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
|
['name' => 'Faces#preview', 'url' => '/api/faces/preview/{id}', 'verb' => 'GET'],
|
||||||
|
|
||||||
['name' => 'Image#info', 'url' => '/api/info/{id}', 'verb' => 'GET'],
|
['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'],
|
||||||
['name' => 'Image#edit', 'url' => '/api/edit/{id}', 'verb' => 'PATCH'],
|
['name' => 'Image#edit', 'url' => '/api/image/edit/{id}', 'verb' => 'PATCH'],
|
||||||
|
['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PUT'],
|
||||||
|
|
||||||
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
|
['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'],
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,11 @@ class ImageController extends ApiBase
|
||||||
$basic = false !== $this->request->getParam('basic', false);
|
$basic = false !== $this->request->getParam('basic', false);
|
||||||
$info = $this->timelineQuery->getInfoById($file->getId(), $basic);
|
$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);
|
return new JSONResponse($info, Http::STATUS_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,4 +118,42 @@ class ImageController extends ApiBase
|
||||||
|
|
||||||
return $this->info($id);
|
return $this->info($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the exif data for a file
|
||||||
|
*/
|
||||||
|
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)) {
|
||||||
|
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 original file from body
|
||||||
|
$exif = $this->request->getParam('raw');
|
||||||
|
$path = $file->getStorage()->getLocalFile($file->getInternalPath());
|
||||||
|
try {
|
||||||
|
Exif::setExif($path, $exif);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reprocess the file
|
||||||
|
$this->timelineWrite->processFile($file, true);
|
||||||
|
|
||||||
|
return new JSONResponse([], Http::STATUS_OK);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,10 +103,6 @@ class TimelineWrite
|
||||||
$videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0);
|
$videoDuration = round($exif['Duration'] ?? $exif['TrackDuration'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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']);
|
|
||||||
|
|
||||||
// Truncate any fields >2048 chars
|
// Truncate any fields >2048 chars
|
||||||
foreach ($exif as $key => &$value) {
|
foreach ($exif as $key => &$value) {
|
||||||
if (\is_string($value) && \strlen($value) > 2048) {
|
if (\is_string($value) && \strlen($value) > 2048) {
|
||||||
|
|
44
lib/Exif.php
44
lib/Exif.php
|
@ -109,7 +109,12 @@ class Exif
|
||||||
throw new \Exception('Failed to get local file path');
|
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 */
|
/** Get exif data as a JSON object from a local file path */
|
||||||
|
@ -357,7 +362,7 @@ class Exif
|
||||||
|
|
||||||
private static function getExifFromLocalPathWithStaticProc(string &$path)
|
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]);
|
fflush(self::$staticPipes[0]);
|
||||||
|
|
||||||
$readyToken = "\n{ready}\n";
|
$readyToken = "\n{ready}\n";
|
||||||
|
@ -379,7 +384,7 @@ class Exif
|
||||||
private static function getExifFromLocalPathWithSeparateProc(string &$path)
|
private static function getExifFromLocalPathWithSeparateProc(string &$path)
|
||||||
{
|
{
|
||||||
$pipes = [];
|
$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'],
|
1 => ['pipe', 'w'],
|
||||||
2 => ['pipe', 'w'],
|
2 => ['pipe', 'w'],
|
||||||
], $pipes);
|
], $pipes);
|
||||||
|
@ -435,4 +440,37 @@ class Exif
|
||||||
throw new \Exception('Could not update exif date: '.$stdout);
|
throw new \Exception('Could not update exif date: '.$stdout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set exif data using raw json.
|
||||||
|
*
|
||||||
|
* @param string $path to local file
|
||||||
|
* @param array $data exif data
|
||||||
|
*
|
||||||
|
* @throws \Exception on failure
|
||||||
|
*/
|
||||||
|
public static function setExif(string &$path, array &$data)
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
|
||||||
|
fwrite($pipes[0], $raw);
|
||||||
|
fclose($pipes[0]);
|
||||||
|
|
||||||
|
$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}");
|
||||||
|
|
||||||
|
throw new \Exception('Could not set exif data: '.$stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,9 @@ import GlobalMixin from "../mixins/GlobalMixin";
|
||||||
import { basename, dirname, extname, join } from "path";
|
import { basename, dirname, extname, join } from "path";
|
||||||
import { emit } from "@nextcloud/event-bus";
|
import { emit } from "@nextcloud/event-bus";
|
||||||
import { showError, showSuccess } from "@nextcloud/dialogs";
|
import { showError, showSuccess } from "@nextcloud/dialogs";
|
||||||
|
import { generateUrl } from "@nextcloud/router";
|
||||||
import axios from "@nextcloud/axios";
|
import axios from "@nextcloud/axios";
|
||||||
|
|
||||||
import FilerobotImageEditor from "filerobot-image-editor";
|
import FilerobotImageEditor from "filerobot-image-editor";
|
||||||
import { FilerobotImageEditorConfig } from "react-filerobot-image-editor";
|
import { FilerobotImageEditorConfig } from "react-filerobot-image-editor";
|
||||||
|
|
||||||
|
@ -25,6 +27,8 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||||
@Prop() mime: string;
|
@Prop() mime: string;
|
||||||
@Prop() src: string;
|
@Prop() src: string;
|
||||||
|
|
||||||
|
private exif: any = null;
|
||||||
|
|
||||||
private imageEditor: FilerobotImageEditor = null;
|
private imageEditor: FilerobotImageEditor = null;
|
||||||
|
|
||||||
get config(): FilerobotImageEditorConfig & { theme: any } {
|
get config(): FilerobotImageEditorConfig & { theme: any } {
|
||||||
|
@ -117,7 +121,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.imageEditor = new FilerobotImageEditor(
|
this.imageEditor = new FilerobotImageEditor(
|
||||||
<any>this.$refs.editor,
|
<any>this.$refs.editor,
|
||||||
<any>this.config
|
<any>this.config
|
||||||
|
@ -125,6 +129,25 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||||
this.imageEditor.render();
|
this.imageEditor.render();
|
||||||
window.addEventListener("keydown", this.handleKeydown, true);
|
window.addEventListener("keydown", this.handleKeydown, true);
|
||||||
window.addEventListener("DOMNodeInserted", this.handleSfxModal);
|
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() {
|
beforeDestroy() {
|
||||||
|
@ -132,6 +155,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||||
this.imageEditor.terminate();
|
this.imageEditor.terminate();
|
||||||
}
|
}
|
||||||
window.removeEventListener("keydown", this.handleKeydown, true);
|
window.removeEventListener("keydown", this.handleKeydown, true);
|
||||||
|
window.removeEventListener("DOMNodeInserted", this.handleSfxModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose(closingReason, haveNotSavedChanges) {
|
onClose(closingReason, haveNotSavedChanges) {
|
||||||
|
@ -173,20 +197,51 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
||||||
// Sanity check, 0 < quality < 1
|
// Sanity check, 0 < quality < 1
|
||||||
quality = Math.max(Math.min(quality, 1), 0) || 1;
|
quality = Math.max(Math.min(quality, 1), 0) || 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.exif &&
|
||||||
|
!confirm(this.t("memories", "No Exif data found! Continue?"))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await new Promise((resolve: BlobCallback) =>
|
const blob = await new Promise((resolve: BlobCallback) =>
|
||||||
imageCanvas.toBlob(resolve, mimeType, quality)
|
imageCanvas.toBlob(resolve, mimeType, quality)
|
||||||
);
|
);
|
||||||
const response = await axios.put(putUrl, new File([blob], fullName));
|
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.put(
|
||||||
|
generateUrl("/apps/memories/api/image/set-exif/{id}", {
|
||||||
|
id: fileid,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
raw: exif,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
showSuccess(this.t("memories", "Image saved successfully"));
|
showSuccess(this.t("memories", "Image saved successfully"));
|
||||||
if (putUrl !== this.src) {
|
if (fileid !== this.fileid) {
|
||||||
emit("files:file:created", {
|
emit("files:file:created", { fileid });
|
||||||
fileid:
|
|
||||||
parseInt(response?.headers?.["oc-fileid"]?.split("oc")[0]) || null,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
emit("files:file:updated", { fileid: this.fileid });
|
emit("files:file:updated", { fileid });
|
||||||
}
|
}
|
||||||
this.onClose(undefined, false);
|
this.onClose(undefined, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -87,7 +87,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
|
||||||
|
|
||||||
let state = this.state;
|
let state = this.state;
|
||||||
const res = await axios.get<any>(
|
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;
|
if (state !== this.state) return;
|
||||||
|
|
||||||
|
@ -193,6 +193,7 @@ export default class Metadata extends Mixins(GlobalMixin) {
|
||||||
const make = this.exif["Make"];
|
const make = this.exif["Make"];
|
||||||
const model = this.exif["Model"];
|
const model = this.exif["Model"];
|
||||||
if (!make || !model) return null;
|
if (!make || !model) return null;
|
||||||
|
if (model.startsWith(make)) return model;
|
||||||
return `${make} ${model}`;
|
return `${make} ${model}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -152,7 +152,7 @@ class VideoContentSetup {
|
||||||
// Get correct orientation
|
// Get correct orientation
|
||||||
axios
|
axios
|
||||||
.get<any>(
|
.get<any>(
|
||||||
generateUrl("/apps/memories/api/info/{id}", {
|
generateUrl("/apps/memories/api/image/info/{id}", {
|
||||||
id: content.data.photo.fileid,
|
id: content.data.photo.fileid,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -286,11 +286,13 @@ export default class Timeline extends Mixins(GlobalMixin, UserConfig) {
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
unsubscribe(this.config_eventName, this.softRefresh);
|
unsubscribe(this.config_eventName, this.softRefresh);
|
||||||
|
unsubscribe("files:file:created", this.softRefresh);
|
||||||
this.resetState();
|
this.resetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
subscribe(this.config_eventName, this.softRefresh);
|
subscribe(this.config_eventName, this.softRefresh);
|
||||||
|
subscribe("files:file:created", this.softRefresh);
|
||||||
window.addEventListener("resize", this.handleResizeWithDelay);
|
window.addEventListener("resize", this.handleResizeWithDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -229,7 +229,6 @@ export default class Viewer extends Mixins(GlobalMixin) {
|
||||||
|
|
||||||
/** Event on file changed */
|
/** Event on file changed */
|
||||||
handleFileUpdated({ fileid }: { fileid: number }) {
|
handleFileUpdated({ fileid }: { fileid: number }) {
|
||||||
console.log("file updated", fileid);
|
|
||||||
if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
|
if (this.currentPhoto && this.currentPhoto.fileid === fileid) {
|
||||||
this.currentPhoto.etag += "_";
|
this.currentPhoto.etag += "_";
|
||||||
this.photoswipe.refreshSlideContent(this.currIndex);
|
this.photoswipe.refreshSlideContent(this.currIndex);
|
||||||
|
|
|
@ -143,8 +143,8 @@ import axios from "@nextcloud/axios";
|
||||||
import * as utils from "../../services/Utils";
|
import * as utils from "../../services/Utils";
|
||||||
import * as dav from "../../services/DavRequests";
|
import * as dav from "../../services/DavRequests";
|
||||||
|
|
||||||
const INFO_API_URL = "/apps/memories/api/info/{id}";
|
const INFO_API_URL = "/apps/memories/api/image/info/{id}";
|
||||||
const EDIT_API_URL = "/apps/memories/api/edit/{id}";
|
const EDIT_API_URL = "/apps/memories/api/image/edit/{id}";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
|
|
Loading…
Reference in New Issue