Edit: Change image orientation through the user interface #464
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
d18e5d3ad3
commit
9ad86ac017
23 changed files with 514 additions and 157 deletions
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -62,6 +62,7 @@ export class File extends RestModel {
|
|||
Width: 0,
|
||||
Height: 0,
|
||||
Orientation: 0,
|
||||
OrientationSrc: "",
|
||||
Projection: "",
|
||||
AspectRatio: 1.0,
|
||||
HDR: false,
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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°" },
|
||||
];
|
||||
|
|
|
@ -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.
|
||||
|
|
106
internal/api/file_orientation.go
Normal file
106
internal/api/file_orientation.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
23
internal/form/file.go
Normal 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
|
||||
}
|
25
internal/form/file_test.go
Normal file
25
internal/form/file_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
11
pkg/clean/orientation.go
Normal 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
|
||||
}
|
27
pkg/clean/orientation_test.go
Normal file
27
pkg/clean/orientation_test.go
Normal 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))
|
||||
})
|
||||
}
|
Loading…
Reference in a new issue