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]
|
||||
|
||||
- **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**: 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))
|
||||
|
|
|
@ -259,7 +259,12 @@ class ImageController extends GenericApiController
|
|||
// Set the exif data
|
||||
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
|
||||
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;
|
||||
$raw = json_encode([$data], JSON_UNESCAPED_UNICODE);
|
||||
$cmd = array_merge(self::getExiftool(), [
|
||||
'-overwrite_original',
|
||||
'-overwrite_original', '-n',
|
||||
'-api', 'LargeFileSupport=1',
|
||||
'-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 AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.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';
|
||||
|
||||
|
@ -231,6 +232,11 @@ export default defineComponent({
|
|||
icon: EditFileIcon,
|
||||
callback: this.editMetadataSelection.bind(this),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'Rotate / Flip'),
|
||||
icon: RotateLeftIcon,
|
||||
callback: () => this.editMetadataSelection(this.selection, [5]),
|
||||
},
|
||||
{
|
||||
name: t('memories', 'View in folder'),
|
||||
icon: OpenInNewIcon,
|
||||
|
|
|
@ -39,6 +39,13 @@
|
|||
</div>
|
||||
<EditLocation ref="editLocation" :photos="photos" :disabled="processing" />
|
||||
</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 v-if="processing" class="progressbar">
|
||||
|
@ -65,6 +72,7 @@ import EditDate from './EditDate.vue';
|
|||
import EditTags from './EditTags.vue';
|
||||
import EditExif from './EditExif.vue';
|
||||
import EditLocation from './EditLocation.vue';
|
||||
import EditOrientation from './EditOrientation.vue';
|
||||
|
||||
import { showError } from '@nextcloud/dialogs';
|
||||
import axios from '@nextcloud/axios';
|
||||
|
@ -86,6 +94,7 @@ export default defineComponent({
|
|||
EditTags,
|
||||
EditExif,
|
||||
EditLocation,
|
||||
EditOrientation,
|
||||
},
|
||||
|
||||
mixins: [UserConfig, ModalMixin],
|
||||
|
@ -105,6 +114,7 @@ export default defineComponent({
|
|||
editTags?: InstanceType<typeof EditTags>;
|
||||
editExif?: InstanceType<typeof EditExif>;
|
||||
editLocation?: InstanceType<typeof EditLocation>;
|
||||
editOrientation?: InstanceType<typeof EditOrientation>;
|
||||
};
|
||||
},
|
||||
},
|
||||
|
@ -225,6 +235,12 @@ export default defineComponent({
|
|||
raw.CreateDate = date;
|
||||
}
|
||||
|
||||
// Orientation
|
||||
const orientation = this.refs.editOrientation?.result?.(p);
|
||||
if (orientation !== null) {
|
||||
raw.Orientation = orientation;
|
||||
}
|
||||
|
||||
exifs.set(p.fileid, raw);
|
||||
}
|
||||
|
||||
|
@ -266,8 +282,20 @@ export default defineComponent({
|
|||
// Update EXIF if required
|
||||
const raw = exifs.get(fileid) ?? {};
|
||||
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;
|
||||
|
||||
// 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
|
||||
|
|
|
@ -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" />
|
||||
</template>
|
||||
</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
|
||||
:aria-label="t('memories', 'Add to album')"
|
||||
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 AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
|
||||
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
|
||||
import RotateLeftIcon from 'vue-material-design-icons/RotateLeft.vue';
|
||||
|
||||
const SLIDESHOW_MS = 5000;
|
||||
const SIDEBAR_DEBOUNCE_MS = 350;
|
||||
|
@ -247,6 +259,7 @@ export default defineComponent({
|
|||
EditFileIcon,
|
||||
AlbumRemoveIcon,
|
||||
AlbumIcon,
|
||||
RotateLeftIcon,
|
||||
},
|
||||
|
||||
mixins: [UserConfig],
|
||||
|
@ -1256,8 +1269,8 @@ export default defineComponent({
|
|||
/**
|
||||
* Edit metadata for current photo
|
||||
*/
|
||||
editMetadata() {
|
||||
_m.modals.editMetadata([this.currentPhoto!]);
|
||||
editMetadata(sections?: number[]) {
|
||||
_m.modals.editMetadata([this.currentPhoto!], sections);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue