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 duration on video tiles
|
||||
- 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)
|
||||
|
||||
|
|
|
@ -52,8 +52,9 @@ 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#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'],
|
||||
|
||||
|
|
|
@ -55,6 +55,11 @@ class ImageController extends ApiBase
|
|||
$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);
|
||||
}
|
||||
|
||||
|
@ -113,4 +118,42 @@ class ImageController extends ApiBase
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
foreach ($exif as $key => &$value) {
|
||||
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');
|
||||
}
|
||||
|
||||
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 */
|
||||
|
@ -357,7 +362,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 +384,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);
|
||||
|
@ -435,4 +440,37 @@ class Exif
|
|||
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 { 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,6 +27,8 @@ 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 } {
|
||||
|
@ -117,7 +121,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
};
|
||||
}
|
||||
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.imageEditor = new FilerobotImageEditor(
|
||||
<any>this.$refs.editor,
|
||||
<any>this.config
|
||||
|
@ -125,6 +129,25 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
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() {
|
||||
|
@ -132,6 +155,7 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
this.imageEditor.terminate();
|
||||
}
|
||||
window.removeEventListener("keydown", this.handleKeydown, true);
|
||||
window.removeEventListener("DOMNodeInserted", this.handleSfxModal);
|
||||
}
|
||||
|
||||
onClose(closingReason, haveNotSavedChanges) {
|
||||
|
@ -173,20 +197,51 @@ export default class ImageEditor extends Mixins(GlobalMixin) {
|
|||
// 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 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"));
|
||||
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) {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
@ -152,7 +152,7 @@ class VideoContentSetup {
|
|||
// Get correct orientation
|
||||
axios
|
||||
.get<any>(
|
||||
generateUrl("/apps/memories/api/info/{id}", {
|
||||
generateUrl("/apps/memories/api/image/info/{id}", {
|
||||
id: content.data.photo.fileid,
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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/edit/{id}";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
|
|
Loading…
Reference in New Issue