Restore metadata after image edit (fix #174)

pull/221/head
Varun Patil 2022-11-09 21:39:13 -08:00
parent 6454d439f5
commit 0dc4784f1a
11 changed files with 157 additions and 21 deletions

View File

@ -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)

View File

@ -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'],

View File

@ -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);
}
} }

View File

@ -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) {

View File

@ -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);
}
}
} }

View File

@ -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&current=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) {

View File

@ -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}`;
} }

View File

@ -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,
}) })
) )

View File

@ -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);
} }

View File

@ -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);

View File

@ -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: {