Edit: Change image orientation through the user interface #464

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-03-20 16:18:27 +01:00
parent d18e5d3ad3
commit 9ad86ac017
23 changed files with 514 additions and 157 deletions

View file

@ -42,23 +42,27 @@
</td>
<td>
<v-btn v-if="features.download" small depressed dark color="primary-button" class="btn-action action-download"
:disabled="busy"
@click.stop.prevent="downloadFile(file)">
<translate>Download</translate>
</v-btn>
<v-btn v-if="features.edit && (file.FileType === 'jpg' || file.FileType === 'png') && !file.Error && !file.Primary" small depressed dark
color="primary-button"
class="btn-action action-primary"
:disabled="busy"
@click.stop.prevent="primaryFile(file)">
<translate>Primary</translate>
</v-btn>
<v-btn v-if="features.edit && !file.Sidecar && !file.Error && !file.Primary && file.Root === '/'" small
depressed dark color="primary-button"
class="btn-action action-unstack"
:disabled="busy"
@click.stop.prevent="unstackFile(file)">
<translate>Unstack</translate>
</v-btn>
<v-btn v-if="features.delete && !file.Primary" small depressed dark color="primary-button"
class="btn-action action-delete"
:disabled="busy"
@click.stop.prevent="showDeleteDialog(file)">
<translate>Delete</translate>
</v-btn>
@ -191,7 +195,24 @@
<translate>Orientation</translate>
</td>
<td>
<v-icon :class="`orientation-${file.Orientation}`">portrait</v-icon>
<v-select
v-model="file.Orientation"
flat solo
browser-autocomplete="off"
hide-details
color="secondary-dark"
:items="options.Orientations()"
:readonly="!features.edit || (file.FileType !== 'jpg' && file.FileType !== 'png') || file.Error"
:disabled="busy"
class="input-orientation"
@change="changeOrientation(file)">
<template #selection="{ item }">
<span :title="item.text"><v-icon :class="`orientation-${item.value}`">portrait</v-icon></span>
</template>
<template #item="{ item }">
<span :title="item.text"><v-icon :class="`orientation-${item.value}`">portrait</v-icon></span>
</template>
</v-select>
</td>
</tr>
<tr v-if="file.ColorProfile">
@ -259,6 +280,7 @@ import Thumb from "model/thumb";
import {DateTime} from "luxon";
import Notify from "common/notify";
import Util from "common/util";
import * as options from "options/options";
export default {
name: 'PTabPhotoFiles',
@ -279,6 +301,8 @@ export default {
features: this.$config.settings().features,
config: this.$config.values,
readonly: this.$config.get("readonly"),
options: options,
busy: false,
selected: [],
listColumns: [
{
@ -348,6 +372,20 @@ export default {
primaryFile(file) {
this.model.primaryFile(file.UID);
},
changeOrientation(file) {
if (!file) {
return;
}
this.busy = true;
this.model.changeFileOrientation(file).then(() => {
this.$notify.success(this.$gettext("Changes successfully saved"));
this.busy = false;
}).catch(() => {
this.busy = false;
});
},
formatTime(s) {
return DateTime.fromISO(s).toLocaleString(DateTime.DATETIME_MED);
},

View file

@ -62,6 +62,7 @@ export class File extends RestModel {
Width: 0,
Height: 0,
Orientation: 0,
OrientationSrc: "",
Projection: "",
AspectRatio: 1.0,
HDR: false,

View file

@ -941,6 +941,26 @@ export class Photo extends RestModel {
);
}
changeFileOrientation(file) {
// Return if no file was provided.
if (!file) {
return Promise.resolve(this);
}
// Get updated values.
const values = file.getValues(true);
// Return if no values were changed.
if (Object.keys(values).length === 0) {
return Promise.resolve(this);
}
// Change file orientation.
return Api.put(`${this.getEntityResource()}/files/${file.UID}/orientation`, values).then((r) =>
Promise.resolve(this.setValues(r.data))
);
}
like() {
this.Favorite = true;
return Api.post(this.getEntityResource() + "/like");

View file

@ -443,3 +443,10 @@ export const Gender = () => [
{ value: "female", text: $gettext("Female") },
{ value: "other", text: $gettext("Other") },
];
export const Orientations = () => [
{ value: 1, text: "" },
{ value: 6, text: "90°" },
{ value: 3, text: "180°" },
{ value: 8, text: "270°" },
];

View file

@ -4,15 +4,15 @@ import (
"net/http"
"path/filepath"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
)
// DeleteFile removes a file from storage.

View file

@ -0,0 +1,106 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
)
// ChangeFileOrientation changes the orientation of a file.
// PUT /api/v1/photos/:uid/files/:file_uid/orientation
//
// Parameters:
//
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func ChangeFileOrientation(router *gin.RouterGroup) {
router.PUT("/photos/:uid/files/:file_uid/orientation", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionUpdate)
if s.Abort(c) {
return
}
conf := get.Config()
// Abort in read-only mode or if editing is disabled.
if conf.ReadOnly() || !conf.Settings().Features.Edit {
Abort(c, http.StatusForbidden, i18n.ErrReadOnly)
return
} else if conf.DisableExifTool() {
c.AbortWithStatusJSON(http.StatusInternalServerError, "exiftool is disabled")
return
}
fileUid := clean.UID(c.Param("file_uid"))
m, err := query.FileByUID(fileUid)
// Abort if the file was not found.
if err != nil {
log.Errorf("files: %s (change orientation)", err)
AbortEntityNotFound(c)
return
}
// Init form with model values
f, err := form.NewFile(m)
if err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
// Update form with values from request
if err = c.BindJSON(&f); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
}
// Update orientation if it was changed.
if m.Orientation() != f.Orientation() {
fileName := photoprism.FileName(m.FileRoot, m.FileName)
mf, err := photoprism.NewMediaFile(fileName)
// Check if file exists.
if err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrFileNotFound)
return
}
// Update file header.
if err = mf.ChangeOrientation(f.Orientation()); err != nil {
log.Debugf("file: %s in %s (change orientation)", err, clean.Log(mf.BaseName()))
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
// Update index.
ind := get.Index()
if res := ind.FileName(mf.FileName(), photoprism.IndexOptionsSingle()); res.Failed() {
log.Errorf("file: %s in %s (change orientation)", res.Err, clean.Log(mf.BaseName()))
AbortSaveFailed(c)
return
}
}
// Return updated photo.
p, err := query.PhotoPreloadByUID(m.PhotoUID)
if err != nil {
AbortEntityNotFound(c)
return
}
PublishPhotoEvent(EntityUpdated, m.PhotoUID, c)
c.JSON(http.StatusOK, p)
})
}

View file

@ -79,7 +79,6 @@ func UpdatePhoto(router *gin.RouterGroup) {
return
}
// TODO: Proof-of-concept for form handling - might need refactoring
// 1) Init form with model values
f, err := form.NewPhoto(m)

View file

@ -38,58 +38,59 @@ var filePrimaryMutex = sync.Mutex{}
// File represents an image or sidecar file that belongs to a photo.
type File struct {
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
Photo *Photo `json:"-" yaml:"-"`
PhotoID uint `gorm:"index:idx_files_photo_id;" json:"-" yaml:"-"`
PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
PhotoTakenAt time.Time `gorm:"type:DATETIME;index;" json:"TakenAt" yaml:"TakenAt"`
TimeIndex *string `gorm:"type:VARBINARY(64);" json:"TimeIndex" yaml:"TimeIndex"`
MediaID *string `gorm:"type:VARBINARY(32);" json:"MediaID" yaml:"MediaID"`
MediaUTC int64 `gorm:"column:media_utc;index;" json:"MediaUTC" yaml:"MediaUTC,omitempty"`
InstanceID string `gorm:"type:VARBINARY(64);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
FileName string `gorm:"type:VARBINARY(1024);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
FileHash string `gorm:"type:VARBINARY(128);index" json:"Hash" yaml:"Hash,omitempty"`
FileSize int64 `json:"Size" yaml:"Size,omitempty"`
FileCodec string `gorm:"type:VARBINARY(32)" json:"Codec" yaml:"Codec,omitempty"`
FileType string `gorm:"type:VARBINARY(16)" json:"FileType" yaml:"FileType,omitempty"`
MediaType string `gorm:"type:VARBINARY(16)" json:"MediaType" yaml:"MediaType,omitempty"`
FileMime string `gorm:"type:VARBINARY(64)" json:"Mime" yaml:"Mime,omitempty"`
FilePrimary bool `gorm:"index:idx_files_photo_id;" json:"Primary" yaml:"Primary,omitempty"`
FileSidecar bool `json:"Sidecar" yaml:"Sidecar,omitempty"`
FileMissing bool `json:"Missing" yaml:"Missing,omitempty"`
FilePortrait bool `json:"Portrait" yaml:"Portrait,omitempty"`
FileVideo bool `json:"Video" yaml:"Video,omitempty"`
FileDuration time.Duration `json:"Duration" yaml:"Duration,omitempty"`
FileFPS float64 `gorm:"column:file_fps;" json:"FPS" yaml:"FPS,omitempty"`
FileFrames int `json:"Frames" yaml:"Frames,omitempty"`
FileWidth int `json:"Width" yaml:"Width,omitempty"`
FileHeight int `json:"Height" yaml:"Height,omitempty"`
FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"`
FileProjection string `gorm:"type:VARBINARY(64);" json:"Projection,omitempty" yaml:"Projection,omitempty"`
FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"`
FileHDR bool `gorm:"column:file_hdr;" json:"HDR" yaml:"HDR,omitempty"`
FileWatermark bool `gorm:"column:file_watermark;" json:"Watermark" yaml:"Watermark,omitempty"`
FileColorProfile string `gorm:"type:VARBINARY(64);" json:"ColorProfile,omitempty" yaml:"ColorProfile,omitempty"`
FileMainColor string `gorm:"type:VARBINARY(16);index;" json:"MainColor" yaml:"MainColor,omitempty"`
FileColors string `gorm:"type:VARBINARY(18);" json:"Colors" yaml:"Colors,omitempty"`
FileLuminance string `gorm:"type:VARBINARY(18);" json:"Luminance" yaml:"Luminance,omitempty"`
FileDiff int `json:"Diff" yaml:"Diff,omitempty"`
FileChroma int16 `json:"Chroma" yaml:"Chroma,omitempty"`
FileSoftware string `gorm:"type:VARCHAR(64)" json:"Software" yaml:"Software,omitempty"`
FileError string `gorm:"type:VARBINARY(512)" json:"Error" yaml:"Error,omitempty"`
ModTime int64 `json:"ModTime" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
UpdatedIn int64 `json:"UpdatedIn" yaml:"-"`
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
Share []FileShare `json:"-" yaml:"-"`
Sync []FileSync `json:"-" yaml:"-"`
markers *Markers
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
Photo *Photo `json:"-" yaml:"-"`
PhotoID uint `gorm:"index:idx_files_photo_id;" json:"-" yaml:"-"`
PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
PhotoTakenAt time.Time `gorm:"type:DATETIME;index;" json:"TakenAt" yaml:"TakenAt"`
TimeIndex *string `gorm:"type:VARBINARY(64);" json:"TimeIndex" yaml:"TimeIndex"`
MediaID *string `gorm:"type:VARBINARY(32);" json:"MediaID" yaml:"MediaID"`
MediaUTC int64 `gorm:"column:media_utc;index;" json:"MediaUTC" yaml:"MediaUTC,omitempty"`
InstanceID string `gorm:"type:VARBINARY(64);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
FileName string `gorm:"type:VARBINARY(1024);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
FileHash string `gorm:"type:VARBINARY(128);index" json:"Hash" yaml:"Hash,omitempty"`
FileSize int64 `json:"Size" yaml:"Size,omitempty"`
FileCodec string `gorm:"type:VARBINARY(32)" json:"Codec" yaml:"Codec,omitempty"`
FileType string `gorm:"type:VARBINARY(16)" json:"FileType" yaml:"FileType,omitempty"`
MediaType string `gorm:"type:VARBINARY(16)" json:"MediaType" yaml:"MediaType,omitempty"`
FileMime string `gorm:"type:VARBINARY(64)" json:"Mime" yaml:"Mime,omitempty"`
FilePrimary bool `gorm:"index:idx_files_photo_id;" json:"Primary" yaml:"Primary,omitempty"`
FileSidecar bool `json:"Sidecar" yaml:"Sidecar,omitempty"`
FileMissing bool `json:"Missing" yaml:"Missing,omitempty"`
FilePortrait bool `json:"Portrait" yaml:"Portrait,omitempty"`
FileVideo bool `json:"Video" yaml:"Video,omitempty"`
FileDuration time.Duration `json:"Duration" yaml:"Duration,omitempty"`
FileFPS float64 `gorm:"column:file_fps;" json:"FPS" yaml:"FPS,omitempty"`
FileFrames int `json:"Frames" yaml:"Frames,omitempty"`
FileWidth int `json:"Width" yaml:"Width,omitempty"`
FileHeight int `json:"Height" yaml:"Height,omitempty"`
FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"`
FileOrientationSrc string `gorm:"type:VARBINARY(8);default:'';" json:"OrientationSrc" yaml:"OrientationSrc,omitempty"`
FileProjection string `gorm:"type:VARBINARY(64);" json:"Projection,omitempty" yaml:"Projection,omitempty"`
FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"`
FileHDR bool `gorm:"column:file_hdr;" json:"HDR" yaml:"HDR,omitempty"`
FileWatermark bool `gorm:"column:file_watermark;" json:"Watermark" yaml:"Watermark,omitempty"`
FileColorProfile string `gorm:"type:VARBINARY(64);" json:"ColorProfile,omitempty" yaml:"ColorProfile,omitempty"`
FileMainColor string `gorm:"type:VARBINARY(16);index;" json:"MainColor" yaml:"MainColor,omitempty"`
FileColors string `gorm:"type:VARBINARY(18);" json:"Colors" yaml:"Colors,omitempty"`
FileLuminance string `gorm:"type:VARBINARY(18);" json:"Luminance" yaml:"Luminance,omitempty"`
FileDiff int `json:"Diff" yaml:"Diff,omitempty"`
FileChroma int16 `json:"Chroma" yaml:"Chroma,omitempty"`
FileSoftware string `gorm:"type:VARCHAR(64)" json:"Software" yaml:"Software,omitempty"`
FileError string `gorm:"type:VARBINARY(512)" json:"Error" yaml:"Error,omitempty"`
ModTime int64 `json:"ModTime" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
UpdatedIn int64 `json:"UpdatedIn" yaml:"-"`
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
Share []FileShare `json:"-" yaml:"-"`
Sync []FileSync `json:"-" yaml:"-"`
markers *Markers
}
// TableName returns the entity table name.
@ -815,3 +816,25 @@ func (m *File) UnsavedMarkers() bool {
func (m *File) SubjectNames() []string {
return m.Markers().SubjectNames()
}
// Orientation returns the file's Exif orientation value.
func (m *File) Orientation() int {
return clean.Orientation(m.FileOrientation)
}
// SetOrientation sets the file's Exif orientation value.
func (m *File) SetOrientation(val int, src string) *File {
// Ignore invalid values.
val = clean.Orientation(val)
if val == 0 {
return m
}
// Only set values with a matching or higher priority.
if SrcPriority[src] >= SrcPriority[m.FileOrientationSrc] {
m.FileOrientation = val
m.FileOrientationSrc = src
}
return m
}

View file

@ -8,96 +8,98 @@ import (
// MarshalJSON returns the JSON encoding.
func (m *File) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
UID string
PhotoUID string
Name string
Root string
Hash string
Size int64
Primary bool
TimeIndex *string `json:",omitempty"`
MediaID *string `json:",omitempty"`
MediaUTC int64 `json:",omitempty"`
InstanceID string `json:",omitempty"`
OriginalName string `json:",omitempty"`
Codec string `json:",omitempty"`
FileType string `json:",omitempty"`
MediaType string `json:",omitempty"`
Mime string `json:",omitempty"`
Sidecar bool `json:",omitempty"`
Missing bool `json:",omitempty"`
Portrait bool `json:",omitempty"`
Video bool `json:",omitempty"`
Duration time.Duration `json:",omitempty"`
FPS float64 `json:",omitempty"`
Frames int `json:",omitempty"`
Width int `json:",omitempty"`
Height int `json:",omitempty"`
Orientation int `json:",omitempty"`
Projection string `json:",omitempty"`
AspectRatio float32 `json:",omitempty"`
ColorProfile string `json:",omitempty"`
MainColor string `json:",omitempty"`
Colors string `json:",omitempty"`
Luminance string `json:",omitempty"`
Diff int `json:",omitempty"`
Chroma int16 `json:",omitempty"`
HDR bool `json:",omitempty"`
Watermark bool `json:",omitempty"`
Software string `json:",omitempty"`
Error string `json:",omitempty"`
ModTime int64 `json:",omitempty"`
CreatedAt time.Time `json:",omitempty"`
CreatedIn int64 `json:",omitempty"`
UpdatedAt time.Time `json:",omitempty"`
UpdatedIn int64 `json:",omitempty"`
DeletedAt *time.Time `json:",omitempty"`
Markers *Markers `json:",omitempty"`
UID string
PhotoUID string
Name string
Root string
Hash string
Size int64
Primary bool
TimeIndex *string `json:",omitempty"`
MediaID *string `json:",omitempty"`
MediaUTC int64 `json:",omitempty"`
InstanceID string `json:",omitempty"`
OriginalName string `json:",omitempty"`
Codec string `json:",omitempty"`
FileType string `json:",omitempty"`
MediaType string `json:",omitempty"`
Mime string `json:",omitempty"`
Sidecar bool `json:",omitempty"`
Missing bool `json:",omitempty"`
Portrait bool `json:",omitempty"`
Video bool `json:",omitempty"`
Duration time.Duration `json:",omitempty"`
FPS float64 `json:",omitempty"`
Frames int `json:",omitempty"`
Width int `json:",omitempty"`
Height int `json:",omitempty"`
Orientation int `json:",omitempty"`
OrientationSrc string `json:",omitempty"`
Projection string `json:",omitempty"`
AspectRatio float32 `json:",omitempty"`
ColorProfile string `json:",omitempty"`
MainColor string `json:",omitempty"`
Colors string `json:",omitempty"`
Luminance string `json:",omitempty"`
Diff int `json:",omitempty"`
Chroma int16 `json:",omitempty"`
HDR bool `json:",omitempty"`
Watermark bool `json:",omitempty"`
Software string `json:",omitempty"`
Error string `json:",omitempty"`
ModTime int64 `json:",omitempty"`
CreatedAt time.Time `json:",omitempty"`
CreatedIn int64 `json:",omitempty"`
UpdatedAt time.Time `json:",omitempty"`
UpdatedIn int64 `json:",omitempty"`
DeletedAt *time.Time `json:",omitempty"`
Markers *Markers `json:",omitempty"`
}{
UID: m.FileUID,
PhotoUID: m.PhotoUID,
Name: m.FileName,
Root: m.FileRoot,
Hash: m.FileHash,
Size: m.FileSize,
Primary: m.FilePrimary,
MediaUTC: m.MediaUTC,
TimeIndex: m.TimeIndex,
MediaID: m.MediaID,
InstanceID: m.InstanceID,
OriginalName: m.OriginalName,
Codec: m.FileCodec,
FileType: m.FileType,
MediaType: m.MediaType,
Mime: m.FileMime,
Sidecar: m.FileSidecar,
Missing: m.FileMissing,
Portrait: m.FilePortrait,
Video: m.FileVideo,
Duration: m.FileDuration,
FPS: m.FileFPS,
Frames: m.FileFrames,
Width: m.FileWidth,
Height: m.FileHeight,
Orientation: m.FileOrientation,
Projection: m.FileProjection,
AspectRatio: m.FileAspectRatio,
ColorProfile: m.FileColorProfile,
MainColor: m.FileMainColor,
Colors: m.FileColors,
Luminance: m.FileLuminance,
Diff: m.FileDiff,
Chroma: m.FileChroma,
HDR: m.FileHDR,
Watermark: m.FileWatermark,
Software: m.FileSoftware,
Error: m.FileError,
ModTime: m.ModTime,
CreatedAt: m.CreatedAt,
CreatedIn: m.CreatedIn,
UpdatedAt: m.UpdatedAt,
UpdatedIn: m.UpdatedIn,
DeletedAt: m.DeletedAt,
Markers: m.Markers(),
UID: m.FileUID,
PhotoUID: m.PhotoUID,
Name: m.FileName,
Root: m.FileRoot,
Hash: m.FileHash,
Size: m.FileSize,
Primary: m.FilePrimary,
MediaUTC: m.MediaUTC,
TimeIndex: m.TimeIndex,
MediaID: m.MediaID,
InstanceID: m.InstanceID,
OriginalName: m.OriginalName,
Codec: m.FileCodec,
FileType: m.FileType,
MediaType: m.MediaType,
Mime: m.FileMime,
Sidecar: m.FileSidecar,
Missing: m.FileMissing,
Portrait: m.FilePortrait,
Video: m.FileVideo,
Duration: m.FileDuration,
FPS: m.FileFPS,
Frames: m.FileFrames,
Width: m.FileWidth,
Height: m.FileHeight,
Orientation: m.FileOrientation,
OrientationSrc: m.FileOrientationSrc,
Projection: m.FileProjection,
AspectRatio: m.FileAspectRatio,
ColorProfile: m.FileColorProfile,
MainColor: m.FileMainColor,
Colors: m.FileColors,
Luminance: m.FileLuminance,
Diff: m.FileDiff,
Chroma: m.FileChroma,
HDR: m.FileHDR,
Watermark: m.FileWatermark,
Software: m.FileSoftware,
Error: m.FileError,
ModTime: m.ModTime,
CreatedAt: m.CreatedAt,
CreatedIn: m.CreatedIn,
UpdatedAt: m.UpdatedAt,
UpdatedIn: m.UpdatedIn,
DeletedAt: m.DeletedAt,
Markers: m.Markers(),
})
}

View file

@ -821,3 +821,41 @@ func TestFile_Bitrate(t *testing.T) {
assert.Equal(t, float64(0), m.Bitrate())
})
}
func TestFile_Orientation(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
m := File{FileOrientation: 8}
assert.Equal(t, 8, m.Orientation())
})
t.Run("Empty", func(t *testing.T) {
m := File{FileOrientation: 0}
assert.Equal(t, 0, m.Orientation())
})
t.Run("Invalid", func(t *testing.T) {
m := File{FileOrientation: 10}
assert.Equal(t, 0, m.Orientation())
})
t.Run("Negative", func(t *testing.T) {
m := File{FileOrientation: -1}
assert.Equal(t, 0, m.Orientation())
})
}
func TestFile_SetOrientation(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
m := File{FileOrientation: 8}
assert.Equal(t, 8, m.Orientation())
assert.Equal(t, "", m.FileOrientationSrc)
m.SetOrientation(1, SrcManual)
assert.Equal(t, 1, m.Orientation())
assert.Equal(t, SrcManual, m.FileOrientationSrc)
})
t.Run("Invalid", func(t *testing.T) {
m := File{FileOrientation: 8}
assert.Equal(t, 8, m.Orientation())
assert.Equal(t, "", m.FileOrientationSrc)
m.SetOrientation(-1, SrcManual)
assert.Equal(t, 8, m.Orientation())
assert.Equal(t, "", m.FileOrientationSrc)
})
}

23
internal/form/file.go Normal file
View file

@ -0,0 +1,23 @@
package form
import (
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/pkg/clean"
)
// File represents a file edit form.
type File struct {
FileOrientation int `json:"Orientation"`
}
// Orientation returns the Exif orientation value within a valid range or 0 if it is invalid.
func (f *File) Orientation() int {
return clean.Orientation(f.FileOrientation)
}
func NewFile(m interface{}) (f File, err error) {
err = deepcopier.Copy(m).To(&f)
return f, err
}

View file

@ -0,0 +1,25 @@
package form
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewFile(t *testing.T) {
t.Run("Orientation", func(t *testing.T) {
var file = struct {
Orientation int
}{
Orientation: 3,
}
frm, err := NewFile(file)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 3, frm.Orientation)
})
}

View file

@ -13,7 +13,7 @@ import (
)
// ToJson uses exiftool to export metadata to a json file.
func (c *Convert) ToJson(f *MediaFile) (jsonName string, err error) {
func (c *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error) {
if f == nil {
return "", fmt.Errorf("exiftool: file is nil - possible bug")
}

View file

@ -25,7 +25,7 @@ func TestConvert_ToJson(t *testing.T) {
t.Fatal(err)
}
jsonName, err := convert.ToJson(mf)
jsonName, err := convert.ToJson(mf, false)
if err != nil {
t.Fatal(err)
@ -50,7 +50,7 @@ func TestConvert_ToJson(t *testing.T) {
t.Fatal(err)
}
jsonName, err := convert.ToJson(mf)
jsonName, err := convert.ToJson(mf, false)
if err != nil {
t.Fatal(err)
@ -76,7 +76,7 @@ func TestConvert_ToJson(t *testing.T) {
t.Fatal(err)
}
jsonName, err := convert.ToJson(mf)
jsonName, err := convert.ToJson(mf, false)
if err != nil {
t.Fatal(err)

View file

@ -25,7 +25,7 @@ func ConvertWorker(jobs <-chan ConvertJob) {
case job.convert == nil:
continue
case job.file.IsAnimated():
_, _ = job.convert.ToJson(job.file)
_, _ = job.convert.ToJson(job.file, false)
// Create JPEG preview and AVC encoded version for videos.
if _, err := job.convert.ToImage(job.file, job.force); err != nil {

View file

@ -41,7 +41,7 @@ func ImportWorker(jobs <-chan ImportJob) {
// Extract metadata to a JSON file with Exiftool.
if related.Main.NeedsExifToolJson() {
if jsonName, err := imp.convert.ToJson(related.Main); err != nil {
if jsonName, err := imp.convert.ToJson(related.Main, false); err != nil {
log.Tracef("exiftool: %s", clean.Log(err.Error()))
log.Debugf("exiftool: failed parsing %s", clean.Log(related.Main.RootRelName()))
} else if err := related.Main.ReadExifToolJson(); err != nil {
@ -135,7 +135,7 @@ func ImportWorker(jobs <-chan ImportJob) {
// Extract metadata to a JSON file with Exiftool.
if f.NeedsExifToolJson() {
if jsonName, err := imp.convert.ToJson(f); err != nil {
if jsonName, err := imp.convert.ToJson(f, false); err != nil {
log.Tracef("exiftool: %s", clean.Log(err.Error()))
log.Debugf("exiftool: failed parsing %s", clean.Log(f.RootRelName()))
} else {
@ -231,7 +231,7 @@ func ImportWorker(jobs <-chan ImportJob) {
// Extract metadata to a JSON file with Exiftool.
if f.NeedsExifToolJson() {
if jsonName, err := imp.convert.ToJson(f); err != nil {
if jsonName, err := imp.convert.ToJson(f, false); err != nil {
log.Tracef("exiftool: %s", clean.Log(err.Error()))
log.Debugf("exiftool: failed parsing %s", clean.Log(f.RootRelName()))
} else {

View file

@ -32,7 +32,7 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
// Extract metadata to a JSON file with Exiftool.
if f.NeedsExifToolJson() {
if jsonName, err := ind.convert.ToJson(f); err != nil {
if jsonName, err := ind.convert.ToJson(f, false); err != nil {
log.Tracef("exiftool: %s", clean.Log(err.Error()))
log.Debugf("exiftool: failed parsing %s", clean.Log(f.RootRelName()))
} else {

View file

@ -751,7 +751,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
file.FileType = m.FileType().String()
file.MediaType = m.Media().String()
file.FileMime = m.MimeType()
file.FileOrientation = m.Orientation()
file.SetOrientation(m.Orientation(), entity.SrcMeta)
file.ModTime = modTime.UTC().Truncate(time.Second).Unix()
// Detect ICC color profile for JPEGs if still unknown at this point.

View file

@ -59,7 +59,7 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde
// Extract metadata to a JSON file with Exiftool.
if f.NeedsExifToolJson() {
if jsonName, err := ind.convert.ToJson(f); err != nil {
if jsonName, err := ind.convert.ToJson(f, false); err != nil {
log.Tracef("exiftool: %s", clean.Log(err.Error()))
log.Debugf("exiftool: failed parsing %s", clean.Log(f.RootRelName()))
} else {

View file

@ -1,8 +1,12 @@
package photoprism
import (
"bytes"
"errors"
"fmt"
"image"
"os/exec"
"strconv"
"strings"
"time"
@ -151,3 +155,35 @@ func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) {
return nil
}
// ChangeOrientation changes the file orientation.
func (m *MediaFile) ChangeOrientation(val int) (err error) {
if !m.IsPreviewImage() {
// Skip.
return fmt.Errorf("not a preview image")
}
cnf := Config()
cmd := exec.Command(cnf.ExifToolBin(), "-overwrite_original", "-P", "-n", "-ModifyDate<FileModifyDate", "-Orientation="+strconv.Itoa(val), m.FileName())
// Fetch command output.
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = []string{fmt.Sprintf("HOME=%s", cnf.CmdCachePath())}
// Log exact command for debugging in trace mode.
log.Trace(cmd.String())
// Run exiftool command.
if err = cmd.Run(); err != nil {
if stderr.String() != "" {
return errors.New(stderr.String())
} else {
return err
}
}
return nil
}

View file

@ -95,6 +95,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetMomentsTime(APIv1)
api.GetFile(APIv1)
api.DeleteFile(APIv1)
api.ChangeFileOrientation(APIv1)
api.UpdateMarker(APIv1)
api.ClearMarkerSubject(APIv1)
api.PhotoPrimary(APIv1)

11
pkg/clean/orientation.go Normal file
View file

@ -0,0 +1,11 @@
package clean
// Orientation returns the Exif orientation value within a valid range or 0 if it is invalid.
func Orientation(val int) int {
// Ignore invalid values.
if val < 1 || val > 8 {
return 0
}
return val
}

View file

@ -0,0 +1,27 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOrientation(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, 0, Orientation(0))
})
t.Run("Valid", func(t *testing.T) {
assert.Equal(t, 1, Orientation(1))
assert.Equal(t, 3, Orientation(3))
assert.Equal(t, 5, Orientation(5))
assert.Equal(t, 7, Orientation(7))
assert.Equal(t, 8, Orientation(8))
})
t.Run("Invalid", func(t *testing.T) {
assert.Equal(t, 0, Orientation(-1))
assert.Equal(t, 0, Orientation(9))
assert.Equal(t, 0, Orientation(2000))
})
}