edit-meta: add rotate
Signed-off-by: Varun Patil <radialapps@gmail.com>pull/653/merge
parent
75237ba505
commit
8c16eecc11
|
@ -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))
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue