edit-meta: add rotate

Signed-off-by: Varun Patil <radialapps@gmail.com>
pull/653/merge
Varun Patil 2023-11-01 18:45:24 -07:00
parent 75237ba505
commit 8c16eecc11
7 changed files with 327 additions and 5 deletions

View File

@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- **Feature**: RAW files are now hidden (stacked) when another file with the same basename exists ([#537](https://github.com/pulsejet/memories/issues/537), [#152](https://github.com/pulsejet/memories/issues/152), [#419](https://github.com/pulsejet/memories/issues/419)) - **Feature**: RAW files are now hidden (stacked) when another file with the same basename exists ([#537](https://github.com/pulsejet/memories/issues/537), [#152](https://github.com/pulsejet/memories/issues/152), [#419](https://github.com/pulsejet/memories/issues/419))
- **Feature**: Bulk rotating of images. You can now rotate images losslessly by editing the rotation EXIF metadata. ([#856](https://github.com/pulsejet/memories/issues/856))
- **Feature**: Icon animation when playing live photos ([#898](https://github.com/pulsejet/memories/issues/898)) - **Feature**: Icon animation when playing live photos ([#898](https://github.com/pulsejet/memories/issues/898))
- **Feature**: Swipe to refresh on timeline ([#547](https://github.com/pulsejet/memories/issues/547)) - **Feature**: Swipe to refresh on timeline ([#547](https://github.com/pulsejet/memories/issues/547))
- **Bugfix**: Allow switching video to direct on Safari ([#650](https://github.com/pulsejet/memories/issues/650)) - **Bugfix**: Allow switching video to direct on Safari ([#650](https://github.com/pulsejet/memories/issues/650))

View File

@ -259,7 +259,12 @@ class ImageController extends GenericApiController
// Set the exif data // Set the exif data
Exif::setFileExif($file, $raw); Exif::setFileExif($file, $raw);
return new JSONResponse([], Http::STATUS_OK); // If rotation changed then update the previews
if ($raw['Orientation'] ?? false) {
$this->deletePreviews($file);
}
return $this->info($id, true);
}); });
} }
@ -452,4 +457,22 @@ class ImageController extends GenericApiController
// Get the tag names // Get the tag names
return array_map(static fn ($t) => $t->getName(), $visible); return array_map(static fn ($t) => $t->getName(), $visible);
} }
/**
* Invalidate previews for a file.
*/
private function deletePreviews(\OCP\Files\File $file): void
{
try {
$previewRoot = new \OC\Preview\Storage\Root(
\OC::$server->get(IRootFolder::class),
\OC::$server->get(\OC\SystemConfig::class),
);
$fileId = (string) $file->getId();
$previewRoot->getFolder($fileId)->delete();
} catch (\Exception $e) {
return;
}
}
} }

View File

@ -334,7 +334,7 @@ class Exif
$data['SourceFile'] = $path; $data['SourceFile'] = $path;
$raw = json_encode([$data], JSON_UNESCAPED_UNICODE); $raw = json_encode([$data], JSON_UNESCAPED_UNICODE);
$cmd = array_merge(self::getExiftool(), [ $cmd = array_merge(self::getExiftool(), [
'-overwrite_original', '-overwrite_original', '-n',
'-api', 'LargeFileSupport=1', '-api', 'LargeFileSupport=1',
'-json=-', $path, '-json=-', $path,
]); ]);

View File

@ -62,6 +62,7 @@ import MoveIcon from 'vue-material-design-icons/ImageMove.vue';
import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue'; import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue'; import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
import type { IDay, IHeadRow, IPhoto, IRow } from '@typings'; import type { IDay, IHeadRow, IPhoto, IRow } from '@typings';
@ -231,6 +232,11 @@ export default defineComponent({
icon: EditFileIcon, icon: EditFileIcon,
callback: this.editMetadataSelection.bind(this), callback: this.editMetadataSelection.bind(this),
}, },
{
name: t('memories', 'Rotate / Flip'),
icon: RotateLeftIcon,
callback: () => this.editMetadataSelection(this.selection, [5]),
},
{ {
name: t('memories', 'View in folder'), name: t('memories', 'View in folder'),
icon: OpenInNewIcon, icon: OpenInNewIcon,

View File

@ -39,6 +39,13 @@
</div> </div>
<EditLocation ref="editLocation" :photos="photos" :disabled="processing" /> <EditLocation ref="editLocation" :photos="photos" :disabled="processing" />
</div> </div>
<div v-if="sections.includes(5)">
<div class="title-text">
{{ t('memories', 'Rotation') }}
</div>
<EditOrientation ref="editOrientation" :photos="photos" :disabled="processing" />
</div>
</div> </div>
<div v-if="processing" class="progressbar"> <div v-if="processing" class="progressbar">
@ -65,6 +72,7 @@ import EditDate from './EditDate.vue';
import EditTags from './EditTags.vue'; import EditTags from './EditTags.vue';
import EditExif from './EditExif.vue'; import EditExif from './EditExif.vue';
import EditLocation from './EditLocation.vue'; import EditLocation from './EditLocation.vue';
import EditOrientation from './EditOrientation.vue';
import { showError } from '@nextcloud/dialogs'; import { showError } from '@nextcloud/dialogs';
import axios from '@nextcloud/axios'; import axios from '@nextcloud/axios';
@ -86,6 +94,7 @@ export default defineComponent({
EditTags, EditTags,
EditExif, EditExif,
EditLocation, EditLocation,
EditOrientation,
}, },
mixins: [UserConfig, ModalMixin], mixins: [UserConfig, ModalMixin],
@ -105,6 +114,7 @@ export default defineComponent({
editTags?: InstanceType<typeof EditTags>; editTags?: InstanceType<typeof EditTags>;
editExif?: InstanceType<typeof EditExif>; editExif?: InstanceType<typeof EditExif>;
editLocation?: InstanceType<typeof EditLocation>; editLocation?: InstanceType<typeof EditLocation>;
editOrientation?: InstanceType<typeof EditOrientation>;
}; };
}, },
}, },
@ -225,6 +235,12 @@ export default defineComponent({
raw.CreateDate = date; raw.CreateDate = date;
} }
// Orientation
const orientation = this.refs.editOrientation?.result?.(p);
if (orientation !== null) {
raw.Orientation = orientation;
}
exifs.set(p.fileid, raw); exifs.set(p.fileid, raw);
} }
@ -266,8 +282,20 @@ export default defineComponent({
// Update EXIF if required // Update EXIF if required
const raw = exifs.get(fileid) ?? {}; const raw = exifs.get(fileid) ?? {};
if (Object.keys(raw).length > 0) { if (Object.keys(raw).length > 0) {
await axios.patch<null>(API.IMAGE_SETEXIF(fileid), { raw }); const info = await axios.patch<IImageInfo>(API.IMAGE_SETEXIF(fileid), { raw });
dirty = true; dirty = true;
// Update image size
p.h = info.data?.h ?? p.h;
p.w = info.data?.w ?? p.w;
// If orientation was updated we need to change
// the ETag so that the preview is updated.
// Deliberately don't change the tag otherwise,
// so there's no need to re-download the image.
if (raw.Orientation) {
p.etag = info.data?.etag ?? p.etag;
}
} }
// Update tags if required // Update tags if required

View File

@ -0,0 +1,251 @@
<template>
<div class="edit-orientation" v-if="samples.length">
<div class="samples">
<XImg v-for="src of samples" class="sample" :key="src" :src="src" :style="{ transform }" />
<div class="sample more" v-if="photos.length > samples.length">
<span>+{{ photos.length - samples.length }}</span>
</div>
</div>
<NcActions :inline="3" class="actions">
<NcActionButton :aria-label="t('memories', 'Rotate Left')" :disabled="disabled" @click="doleft">
<template #icon> <RotateLeftIcon :size="22" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Rotate Right')" :disabled="disabled" @click="doright">
<template #icon> <RotateRightIcon :size="22" /> </template>
</NcActionButton>
<NcActionButton :aria-label="t('memories', 'Flip')" :disabled="disabled" @click="doflip">
<template #icon> <FlipHorizontalIcon :size="22" /> </template>
</NcActionButton>
</NcActions>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as utils from '@services/utils';
import NcActions from '@nextcloud/vue/dist/Components/NcActions';
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
import RotateRightIcon from 'vue-material-design-icons/RotateRight.vue';
import FlipHorizontalIcon from 'vue-material-design-icons/FlipHorizontal.vue';
import type { IPhoto } from '@typings';
const NORMAL = [1, 6, 3, 8];
const FLIPPED = [2, 7, 4, 5];
export default defineComponent({
components: {
NcActions,
NcActionButton,
RotateLeftIcon,
RotateRightIcon,
FlipHorizontalIcon,
},
props: {
photos: {
type: Array<IPhoto>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: {
save: () => true,
},
data: () => ({
/** Current state relative to 1 */
state: 1,
/** Full (360) rotations for CSS transition */
spins: 0,
}),
computed: {
samples() {
return this.photos.slice(0, 8).map((photo) => utils.getPreviewUrl({ photo, size: 512 }));
},
transform() {
const f = this.isflip(this.state) ? -1 : 1;
const d = this.spins;
return `${this.transform1} rotate(${d * 360 * f}deg)`;
},
transform1() {
/**
* 1 = Horizontal (normal)
* 2 = Mirror horizontal
* 3 = Rotate 180
* 4 = Mirror vertical
* 5 = Mirror horizontal and rotate 270 CW
* 6 = Rotate 90 CW
* 7 = Mirror horizontal and rotate 90 CW
* 8 = Rotate 270 CW
*/
if (this.state < 1 || this.state > 8) {
console.error('Invalid orientation state', this.state);
return null;
}
switch (this.state) {
case 1:
return 'rotate(0deg)';
case 2:
return 'scaleX(-1)';
case 3:
return 'rotate(180deg)';
case 4:
return 'scaleX(-1) rotate(-180deg)';
case 5:
return 'scaleX(-1) rotate(-270deg)';
case 6:
return 'rotate(90deg)';
case 7:
return 'scaleX(-1) rotate(-90deg)';
case 8:
return 'rotate(270deg)';
}
},
},
methods: {
/**
* Get target orientation state for a photo.
* If no change is needed, return null.
*/
result(photo: IPhoto): number | null {
const exif = photo.imageInfo?.exif;
if (!exif) return null;
let state = Number(exif.Orientation) || 1;
const oldState = state;
// Check if state is valid
if (state < 1 || state > 8) {
state = 1;
}
// Flip state if needed
if (this.isflip(this.state)) {
state = this.flip(state);
}
// Rotate state by index difference
const cindex = this.list(this.state).indexOf(this.state);
const list = this.list(state);
const sindex = list.indexOf(state);
state = list[(cindex + sindex) % list.length];
// No change
if ((!exif.Orientation && state === 1) || state === oldState) {
return null;
}
return state;
},
doleft() {
const list = this.list(this.state);
let index = list.indexOf(this.state) - 1;
if (index < 0) {
this.spins--;
index = list.length - 1;
}
this.state = list[index];
},
doright() {
const list = this.list(this.state);
let index = list.indexOf(this.state) + 1;
if (index === list.length) {
this.spins++;
index = 0;
}
this.state = list[index];
},
doflip() {
this.state = this.flip(this.state);
},
/** Flip a state in-place */
flip(state: number) {
if (this.isflip(state)) {
let i = FLIPPED.indexOf(state);
if (i === 1) i = 3;
else if (i === 3) i = 1;
return NORMAL[i];
} else {
let i = NORMAL.indexOf(state);
if (i === 1) i = 3;
else if (i === 3) i = 1;
return FLIPPED[i];
}
},
/** Check if a state is flipped */
isflip(state: number) {
return FLIPPED.includes(state);
},
/** Get rotation list for this state (flipped / regular) */
list(state: number) {
return this.isflip(state) ? FLIPPED : NORMAL;
},
},
});
</script>
<style scoped lang="scss">
.edit-orientation {
margin: 4px 0;
text-align: center;
.samples {
display: grid;
grid-gap: 5px;
grid-template-columns: repeat(auto-fit, 80px);
justify-content: center;
margin: 4px 0;
.sample {
border-radius: 10px;
object-fit: cover;
width: 100%;
aspect-ratio: 1 / 1;
transition: transform 0.2s ease-in-out;
&:first-child {
grid-column: span 2;
grid-row: span 2;
}
}
.more {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-background-dark);
> span {
font-size: 1.3em;
font-weight: 500;
transform: translate(-3px, -3px);
}
}
}
.actions {
justify-content: center;
}
}
</style>

View File

@ -155,6 +155,17 @@
<EditFileIcon :size="24" /> <EditFileIcon :size="24" />
</template> </template>
</NcActionButton> </NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Rotate / Flip')"
v-if="canEdit && !isVideo"
@click="editMetadata([5])"
:close-after-click="true"
>
{{ t('memories', 'Rotate / Flip') }}
<template #icon>
<RotateLeftIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton <NcActionButton
:aria-label="t('memories', 'Add to album')" :aria-label="t('memories', 'Add to album')"
v-if="config.albums_enabled && !isLocal && !routeIsPublic && canShare && currentPhoto?.imageInfo?.filename" v-if="config.albums_enabled && !isLocal && !routeIsPublic && canShare && currentPhoto?.imageInfo?.filename"
@ -221,6 +232,7 @@ import SlideshowIcon from 'vue-material-design-icons/PlayBox.vue';
import EditFileIcon from 'vue-material-design-icons/FileEdit.vue'; import EditFileIcon from 'vue-material-design-icons/FileEdit.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue'; import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
const SLIDESHOW_MS = 5000; const SLIDESHOW_MS = 5000;
const SIDEBAR_DEBOUNCE_MS = 350; const SIDEBAR_DEBOUNCE_MS = 350;
@ -247,6 +259,7 @@ export default defineComponent({
EditFileIcon, EditFileIcon,
AlbumRemoveIcon, AlbumRemoveIcon,
AlbumIcon, AlbumIcon,
RotateLeftIcon,
}, },
mixins: [UserConfig], mixins: [UserConfig],
@ -1256,8 +1269,8 @@ export default defineComponent({
/** /**
* Edit metadata for current photo * Edit metadata for current photo
*/ */
editMetadata() { editMetadata(sections?: number[]) {
_m.modals.editMetadata([this.currentPhoto!]); _m.modals.editMetadata([this.currentPhoto!], sections);
}, },
/** /**