mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Merge 3439b311fa into 0cecb5287f
This commit is contained in:
commit
c3335503a7
60 changed files with 2751 additions and 61 deletions
|
|
@ -119,6 +119,12 @@
|
|||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item :to="{ name: 'browse', query: { q: 'fisheye' } }" :exact="true" variant="text" class="nav-fisheye" @click.stop="">
|
||||
<v-list-item-title :class="`nav-menu-item menu-item`">
|
||||
{{ $gettext(`Fisheye`) }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item :to="{ name: 'photos', query: { q: 'stacks' } }" :exact="true" variant="text" class="nav-stacks" @click.stop="">
|
||||
<v-list-item-title :class="`nav-menu-item menu-item`">
|
||||
{{ $gettext(`Stacks`) }}
|
||||
|
|
|
|||
|
|
@ -676,6 +676,9 @@
|
|||
"FFmpegEncoder": {
|
||||
"type": "string"
|
||||
},
|
||||
"FFmpegFisheyeFov": {
|
||||
"type": "integer"
|
||||
},
|
||||
"FFmpegMapAudio": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -140,7 +140,8 @@ func GetVideo(router *gin.RouterGroup) {
|
|||
bitrateExceeded := conf.FFmpegEnabled() && conf.FFmpegBitrateExceeded(videoBitrate)
|
||||
transcode := !supported || bitrateExceeded
|
||||
|
||||
if mediaFile, mediaErr := photoprism.NewMediaFile(videoFileName); mediaErr != nil {
|
||||
mediaFile, mediaErr := photoprism.NewMediaFile(videoFileName)
|
||||
if mediaErr != nil {
|
||||
// Set missing flag so that the file doesn't show up in search results anymore.
|
||||
logErr("video", f.Update("FileMissing", true))
|
||||
|
||||
|
|
@ -148,7 +149,31 @@ func GetVideo(router *gin.RouterGroup) {
|
|||
log.Errorf("video: file %s is missing", clean.Log(f.FileName))
|
||||
AbortVideo(c)
|
||||
return
|
||||
} else if transcode {
|
||||
}
|
||||
|
||||
// Original fisheye pixels must never be sent to the sphere viewer or converted inline in an
|
||||
// HTTP request. Index/import workers create the AVC; an older LRV derivative is a safe fallback.
|
||||
if mediaFile.DewarpableInsv() {
|
||||
playable := photoprism.DewarpedVideoFile(mediaFile)
|
||||
|
||||
if playable == nil {
|
||||
log.Warnf("video: equirectangular derivative for %s is not ready", clean.Log(f.FileName))
|
||||
AbortVideo(c)
|
||||
return
|
||||
}
|
||||
|
||||
mediaFile = playable
|
||||
videoFileName = playable.FileName()
|
||||
videoFileType = playable.FileType()
|
||||
videoContentType = playable.ContentType()
|
||||
info := playable.VideoInfo()
|
||||
videoBitrate = info.VideoBitrate()
|
||||
supported = video.Compatible(videoContentType, format.ContentType)
|
||||
bitrateExceeded = conf.FFmpegEnabled() && conf.FFmpegBitrateExceeded(videoBitrate)
|
||||
transcode = !supported || bitrateExceeded
|
||||
}
|
||||
|
||||
if transcode {
|
||||
if supported && bitrateExceeded {
|
||||
log.Debugf(
|
||||
"video: %s has an average bitrate of %.1f Mbps, which exceeds the %d Mbps limit",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,21 @@ func (c *Config) FFmpegBitrate() int {
|
|||
}
|
||||
}
|
||||
|
||||
// FFmpegFisheyeFov returns the field of view in degrees used by the v360 dewarp filter for fisheye
|
||||
// 360° originals, defaulting to 204 (typical Insta360 X-series lens overlap).
|
||||
func (c *Config) FFmpegFisheyeFov() int {
|
||||
switch {
|
||||
case c.options.FFmpegFisheyeFov <= 0:
|
||||
return encode.DefaultFisheyeFov
|
||||
case c.options.FFmpegFisheyeFov < encode.MinFisheyeFov:
|
||||
return encode.MinFisheyeFov
|
||||
case c.options.FFmpegFisheyeFov > encode.MaxFisheyeFov:
|
||||
return encode.MaxFisheyeFov
|
||||
default:
|
||||
return c.options.FFmpegFisheyeFov
|
||||
}
|
||||
}
|
||||
|
||||
// FFmpegBitrateExceeded tests if the ffmpeg bitrate limit in Mbps is exceeded.
|
||||
func (c *Config) FFmpegBitrateExceeded(bitrate float64) bool {
|
||||
if bitrate <= 0 {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,23 @@ func TestConfig_FFmpegBitrate(t *testing.T) {
|
|||
assert.Equal(t, 800, c.FFmpegBitrate())
|
||||
}
|
||||
|
||||
func TestConfig_FFmpegFisheyeFov(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
assert.Equal(t, encode.DefaultFisheyeFov, c.FFmpegFisheyeFov())
|
||||
|
||||
c.options.FFmpegFisheyeFov = 0
|
||||
assert.Equal(t, encode.DefaultFisheyeFov, c.FFmpegFisheyeFov())
|
||||
|
||||
c.options.FFmpegFisheyeFov = 10
|
||||
assert.Equal(t, encode.MinFisheyeFov, c.FFmpegFisheyeFov())
|
||||
|
||||
c.options.FFmpegFisheyeFov = 500
|
||||
assert.Equal(t, encode.MaxFisheyeFov, c.FFmpegFisheyeFov())
|
||||
|
||||
c.options.FFmpegFisheyeFov = 190
|
||||
assert.Equal(t, 190, c.FFmpegFisheyeFov())
|
||||
}
|
||||
|
||||
func TestConfig_FFmpegSize(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
assert.Equal(t, 4096, c.FFmpegSize())
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,12 @@ var Flags = CliFlags{
|
|||
Value: encode.DefaultBitrateLimit,
|
||||
EnvVars: EnvVars("FFMPEG_BITRATE"),
|
||||
}}, {
|
||||
Flag: &cli.IntFlag{
|
||||
Name: "ffmpeg-fisheye-fov",
|
||||
Usage: fmt.Sprintf("field of view in `DEGREES` for dewarping fisheye 360° originals (%d-%d)", encode.MinFisheyeFov, encode.MaxFisheyeFov),
|
||||
Value: encode.DefaultFisheyeFov,
|
||||
EnvVars: EnvVars("FFMPEG_FISHEYE_FOV"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "ffmpeg-preset",
|
||||
Usage: "FFmpeg compression `PRESET` when using an encoder that supports it, e.g. fast, medium, or slow",
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ type Options struct {
|
|||
FFmpegSize int `yaml:"FFmpegSize" json:"FFmpegSize" flag:"ffmpeg-size"`
|
||||
FFmpegQuality int `yaml:"FFmpegQuality" json:"FFmpegQuality" flag:"ffmpeg-quality"`
|
||||
FFmpegBitrate int `yaml:"FFmpegBitrate" json:"FFmpegBitrate" flag:"ffmpeg-bitrate"`
|
||||
FFmpegFisheyeFov int `yaml:"FFmpegFisheyeFov" json:"FFmpegFisheyeFov" flag:"ffmpeg-fisheye-fov"`
|
||||
FFmpegPreset string `yaml:"FFmpegPreset" json:"FFmpegPreset" flag:"ffmpeg-preset"`
|
||||
FFmpegDevice string `yaml:"FFmpegDevice" json:"-" flag:"ffmpeg-device"`
|
||||
FFmpegMapVideo string `yaml:"FFmpegMapVideo" json:"FFmpegMapVideo" flag:"ffmpeg-map-video"`
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
|||
{"ffmpeg-size", fmt.Sprintf("%d", c.FFmpegSize())},
|
||||
{"ffmpeg-quality", fmt.Sprintf("%d", c.FFmpegQuality())},
|
||||
{"ffmpeg-bitrate", fmt.Sprintf("%d", c.FFmpegBitrate())},
|
||||
{"ffmpeg-fisheye-fov", fmt.Sprintf("%d", c.FFmpegFisheyeFov())},
|
||||
{"ffmpeg-preset", c.FFmpegPreset()},
|
||||
{"ffmpeg-device", c.FFmpegDevice()},
|
||||
{"ffmpeg-map-video", c.FFmpegMapVideo()},
|
||||
|
|
|
|||
41
internal/entity/camera_fov.go
Normal file
41
internal/entity/camera_fov.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package entity
|
||||
|
||||
import "strings"
|
||||
|
||||
// CameraFisheyeFov returns the per-lens field of view in degrees for dewarping fisheye 360°
|
||||
// originals from known cameras, or 0 when unknown so the caller can fall back to the configured
|
||||
// default. Values are approximate and tuned for the overlapping lenses of each model. Only cameras
|
||||
// whose formats are handled (Insta360 .insv/.insp/DNG, Ricoh Theta DNG) are listed, so this stays
|
||||
// in step with MediaFile.FisheyeDng detection.
|
||||
func CameraFisheyeFov(makeName, modelName string) int {
|
||||
maker := strings.ToLower(strings.TrimSpace(makeName))
|
||||
model := strings.ToLower(strings.TrimSpace(modelName))
|
||||
|
||||
switch {
|
||||
case strings.Contains(model, "insta360 x") || strings.Contains(model, "one x") || strings.Contains(model, "one rs") || strings.Contains(model, "oners"):
|
||||
return 204 // Insta360 X-series / ONE X / ONE RS.
|
||||
case strings.Contains(model, "insta360") || maker == "insta360" || maker == "arashi vision":
|
||||
return 200 // Other Insta360 models identified by model or maker (e.g. ONE, bare model names).
|
||||
case strings.Contains(model, "theta"):
|
||||
return 200 // Ricoh Theta series (model always carries "THETA").
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// CameraFisheyeRoll returns the verified spherical roll correction for a known camera profile.
|
||||
func CameraFisheyeRoll(makeName, modelName string) int {
|
||||
maker := strings.ToLower(strings.TrimSpace(makeName))
|
||||
model := strings.ToLower(strings.TrimSpace(modelName))
|
||||
|
||||
if model != "insta360 oners" {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch maker {
|
||||
case "", "insta360", "arashi vision":
|
||||
return 180
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
57
internal/entity/camera_fov_test.go
Normal file
57
internal/entity/camera_fov_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCameraFisheyeFov(t *testing.T) {
|
||||
t.Run("InstaX", func(t *testing.T) {
|
||||
assert.Equal(t, 204, CameraFisheyeFov("Insta360", "Insta360 X4"))
|
||||
})
|
||||
t.Run("InstaOne", func(t *testing.T) {
|
||||
assert.Equal(t, 200, CameraFisheyeFov("Arashi Vision", "Insta360 ONE"))
|
||||
})
|
||||
t.Run("InstaOneRS", func(t *testing.T) {
|
||||
assert.Equal(t, 204, CameraFisheyeFov("Arashi Vision", "Insta360 OneRS"))
|
||||
})
|
||||
t.Run("Theta", func(t *testing.T) {
|
||||
assert.Equal(t, 200, CameraFisheyeFov("RICOH", "RICOH THETA Z1"))
|
||||
})
|
||||
t.Run("MakerOnlyInsta360", func(t *testing.T) {
|
||||
// Bare model name; identified by the maker field (the makeName parameter must be used).
|
||||
assert.Equal(t, 200, CameraFisheyeFov("Insta360", "X4"))
|
||||
})
|
||||
t.Run("MakerOnlyArashiVision", func(t *testing.T) {
|
||||
assert.Equal(t, 200, CameraFisheyeFov("Arashi Vision", ""))
|
||||
})
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
assert.Equal(t, 0, CameraFisheyeFov("Canon", "Canon EOS 6D"))
|
||||
})
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
assert.Equal(t, 0, CameraFisheyeFov("", ""))
|
||||
})
|
||||
}
|
||||
|
||||
// TestCameraFisheyeRoll verifies that only the validated OneRS profile receives a correction.
|
||||
func TestCameraFisheyeRoll(t *testing.T) {
|
||||
t.Run("OneRS", func(t *testing.T) {
|
||||
assert.Equal(t, 180, CameraFisheyeRoll("Insta360", "Insta360 OneRS"))
|
||||
})
|
||||
t.Run("OneRSLegacyMaker", func(t *testing.T) {
|
||||
assert.Equal(t, 180, CameraFisheyeRoll("Arashi Vision", "Insta360 OneRS"))
|
||||
})
|
||||
t.Run("OneRSVideoTrailer", func(t *testing.T) {
|
||||
assert.Equal(t, 180, CameraFisheyeRoll("", "Insta360 OneRS"))
|
||||
})
|
||||
t.Run("OtherInsta360", func(t *testing.T) {
|
||||
assert.Equal(t, 0, CameraFisheyeRoll("Insta360", "Insta360 X4"))
|
||||
})
|
||||
t.Run("ConflictingMaker", func(t *testing.T) {
|
||||
assert.Equal(t, 0, CameraFisheyeRoll("Canon", "Insta360 OneRS"))
|
||||
})
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
assert.Equal(t, 0, CameraFisheyeRoll("", ""))
|
||||
})
|
||||
}
|
||||
|
|
@ -370,6 +370,9 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
|
|||
case terms["panoramas"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "panoramas", "")
|
||||
frm.Panorama = true
|
||||
case terms["fisheye"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "fisheye", "")
|
||||
frm.Fisheye = true
|
||||
case terms["scans"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "scans", "")
|
||||
frm.Scan = "true"
|
||||
|
|
@ -557,6 +560,11 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
|
|||
s = s.Where("photos.photo_panorama = 1")
|
||||
}
|
||||
|
||||
// Find fisheye 360° originals only.
|
||||
if frm.Fisheye {
|
||||
s = fisheyePhotoFilter(s)
|
||||
}
|
||||
|
||||
// Find portrait/landscape/square pictures only.
|
||||
if frm.Portrait {
|
||||
s = s.Where("files.file_portrait = 1")
|
||||
|
|
|
|||
36
internal/entity/search/photos_filter_fisheye_test.go
Normal file
36
internal/entity/search/photos_filter_fisheye_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
)
|
||||
|
||||
func TestPhotosQueryFisheye(t *testing.T) {
|
||||
// The fisheye subquery must build valid SQL and narrow the result set, even when no fixture
|
||||
// matches (real matching is covered by the end-to-end index verification).
|
||||
var fisheye form.SearchPhotos
|
||||
fisheye.Query = "fisheye:true"
|
||||
fisheye.Merged = true
|
||||
|
||||
if err := fisheye.ParseQueryString(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
matched, _, err := Photos(fisheye)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var all form.SearchPhotos
|
||||
all.Merged = true
|
||||
|
||||
total, _, err := Photos(all)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, len(total), len(matched))
|
||||
}
|
||||
|
|
@ -294,6 +294,9 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
|
|||
case terms["panoramas"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "panoramas", "")
|
||||
frm.Panorama = true
|
||||
case terms["fisheye"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "fisheye", "")
|
||||
frm.Fisheye = true
|
||||
case terms["scans"]:
|
||||
frm.Query = strings.ReplaceAll(frm.Query, "scans", "")
|
||||
frm.Scan = "true"
|
||||
|
|
@ -459,6 +462,11 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
|
|||
s = s.Where("photos.photo_panorama = 1")
|
||||
}
|
||||
|
||||
// Find fisheye 360° originals only.
|
||||
if frm.Fisheye {
|
||||
s = fisheyePhotoFilter(s)
|
||||
}
|
||||
|
||||
// Find portrait/landscape/square pictures only.
|
||||
if frm.Portrait {
|
||||
s = s.Where("files.file_portrait = 1")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
|
@ -219,6 +220,13 @@ func (m *Photo) IsPlayable() bool {
|
|||
func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, height int) {
|
||||
switch m.PhotoType {
|
||||
case entity.MediaVideo, entity.MediaLive:
|
||||
// Prefer a generated equirectangular AVC so dimensions, codec, and projection describe the
|
||||
// media the sphere viewer actually plays rather than either square lens original.
|
||||
for _, f := range m.Files {
|
||||
if f.FileVideo && f.FileHash != "" && projection.Type(f.FileProjection).Equal(projection.Equirectangular.String()) {
|
||||
return f.FileHash, f.FileCodec, video.ContentType(f.FileMime, f.FileType, f.FileCodec, f.IsHDR()), f.FileWidth, f.FileHeight
|
||||
}
|
||||
}
|
||||
for _, f := range m.Files {
|
||||
if f.FileVideo && f.FileHash != "" {
|
||||
return f.FileHash, f.FileCodec, video.ContentType(f.FileMime, f.FileType, f.FileCodec, f.IsHDR()), f.FileWidth, f.FileHeight
|
||||
|
|
@ -260,22 +268,53 @@ func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, hei
|
|||
return m.FileHash, "", m.FileMime, m.FileWidth, m.FileHeight
|
||||
}
|
||||
|
||||
// MediaProjection returns the projection of the photo's playable media file,
|
||||
// falling back to the primary file's projection. For videos the primary search
|
||||
// row is usually a poster JPEG that carries no projection metadata, so the video
|
||||
// file's projection (which the indexer sets) is used instead — this keeps the
|
||||
// 360° equirectangular flag accurate in the viewer DTO.
|
||||
// MediaProjection returns the projection of the photo's playable media file, i.e. what the viewer
|
||||
// actually shows. It prefers an equirectangular derivative (the dewarped output of a fisheye or
|
||||
// dual-fisheye original), falling back to the video or primary file's projection. Raw fisheye-family
|
||||
// values are never reported, because those originals are not directly viewable in the sphere viewer.
|
||||
func (m *Photo) MediaProjection() string {
|
||||
// Prefer an equirectangular derivative so the sphere viewer shows the corrected pixels
|
||||
// rather than the raw fisheye source.
|
||||
for _, f := range m.Files {
|
||||
if projection.Type(f.FileProjection).Equal(projection.Equirectangular.String()) {
|
||||
return projection.Equirectangular.String()
|
||||
}
|
||||
}
|
||||
|
||||
switch m.PhotoType {
|
||||
case entity.MediaVideo, entity.MediaLive:
|
||||
// For videos the primary search row is usually a poster JPEG that carries no projection,
|
||||
// so the video file's projection (which the indexer sets) is used instead. Fisheye-family
|
||||
// values are skipped: the playable media is the equirectangular transcode, which the viewer
|
||||
// routes via its 2:1 aspect ratio and the panorama flag.
|
||||
for _, f := range m.Files {
|
||||
if f.FileVideo && f.FileProjection != "" {
|
||||
if f.FileVideo && f.FileProjection != "" && !projection.Type(f.FileProjection).Fisheye() {
|
||||
return f.FileProjection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m.FileProjection
|
||||
// A fisheye/dual-fisheye original without an equirectangular derivative is not directly viewable,
|
||||
// so report no projection rather than the raw fisheye value.
|
||||
return sphereProjection(m.FileProjection)
|
||||
}
|
||||
|
||||
// sphereProjection redacts raw fisheye-family projections, which the sphere viewer cannot display
|
||||
// directly, so only an equirectangular derivative ever routes a photo to it.
|
||||
func sphereProjection(proj string) string {
|
||||
if projection.Type(proj).Fisheye() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return proj
|
||||
}
|
||||
|
||||
// fisheyePhotoFilter restricts the query to photos that have a live fisheye-family original file.
|
||||
// The primary file is the equirectangular dewarp derivative, so the subquery scans all files; it
|
||||
// mirrors the base query's `media_id IS NOT NULL` scope to exclude missing/soft-deleted files.
|
||||
func fisheyePhotoFilter(s *gorm.DB) *gorm.DB {
|
||||
return s.Where("photos.id IN (SELECT photo_id FROM files WHERE media_id IS NOT NULL AND file_projection IN (?))",
|
||||
[]string{projection.Fisheye.String(), projection.DualFisheye.String()})
|
||||
}
|
||||
|
||||
// ShareBase returns a deterministic, human friendly file name stem for sharing
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
|
@ -254,9 +255,70 @@ func TestPhoto_MediaProjection(t *testing.T) {
|
|||
}
|
||||
assert.Equal(t, "equirectangular", r.MediaProjection())
|
||||
})
|
||||
t.Run("ImagePrefersEquirectDerivative", func(t *testing.T) {
|
||||
r := Photo{
|
||||
PhotoType: "image",
|
||||
FileProjection: "dual-fisheye",
|
||||
Files: []entity.File{
|
||||
{MediaType: media.Image.String(), FileHash: "e", FileProjection: "equirectangular"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "equirectangular", r.MediaProjection())
|
||||
})
|
||||
t.Run("ImageDualFisheyeWithoutDerivativeRedacted", func(t *testing.T) {
|
||||
r := Photo{
|
||||
PhotoType: "image",
|
||||
FileProjection: "dual-fisheye",
|
||||
}
|
||||
assert.Equal(t, "", r.MediaProjection())
|
||||
})
|
||||
t.Run("VideoPrefersEquirectDerivative", func(t *testing.T) {
|
||||
r := Photo{
|
||||
PhotoType: "video",
|
||||
FileProjection: "",
|
||||
Files: []entity.File{
|
||||
{FileVideo: true, MediaType: media.Video.String(), FileHash: "v", FileProjection: "dual-fisheye"},
|
||||
{MediaType: media.Image.String(), FileHash: "e", FileProjection: "equirectangular"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "equirectangular", r.MediaProjection())
|
||||
})
|
||||
t.Run("VideoDualFisheyeWithoutDerivativeRedacted", func(t *testing.T) {
|
||||
r := Photo{
|
||||
PhotoType: "video",
|
||||
FileProjection: "dual-fisheye",
|
||||
Files: []entity.File{
|
||||
{FileVideo: true, MediaType: media.Video.String(), FileHash: "v", FileProjection: "dual-fisheye"},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "", r.MediaProjection())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSphereProjection(t *testing.T) {
|
||||
assert.Equal(t, "", sphereProjection("fisheye"))
|
||||
assert.Equal(t, "", sphereProjection("dual-fisheye"))
|
||||
assert.Equal(t, "equirectangular", sphereProjection("equirectangular"))
|
||||
assert.Equal(t, "cubestrip", sphereProjection("cubestrip"))
|
||||
assert.Equal(t, "", sphereProjection(""))
|
||||
}
|
||||
|
||||
func TestPhoto_MediaInfo(t *testing.T) {
|
||||
t.Run("EquirectangularDerivativePreferred", func(t *testing.T) {
|
||||
r := Photo{
|
||||
PhotoType: media.Video.String(),
|
||||
Files: []entity.File{
|
||||
{FileVideo: true, FileHash: "square-original", FileCodec: video.CodecHvc1, FileWidth: 3072, FileHeight: 3072},
|
||||
{FileVideo: true, FileHash: "sphere-avc", FileCodec: video.CodecAvc1, FileMime: header.ContentTypeMp4AvcMain, FileWidth: 1920, FileHeight: 960, FileProjection: projection.Equirectangular.String()},
|
||||
},
|
||||
}
|
||||
|
||||
mediaHash, mediaCodec, _, width, height := r.MediaInfo()
|
||||
assert.Equal(t, "sphere-avc", mediaHash)
|
||||
assert.Equal(t, video.CodecAvc1, mediaCodec)
|
||||
assert.Equal(t, 1920, width)
|
||||
assert.Equal(t, 960, height)
|
||||
})
|
||||
t.Run("LiveCodecAVC", func(t *testing.T) {
|
||||
r := Photo{
|
||||
ID: 1111154,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken
|
|||
Favorite: m.PhotoFavorite,
|
||||
Playable: m.IsPlayable(),
|
||||
Panorama: m.PhotoPanorama,
|
||||
Projection: m.FileProjection,
|
||||
Projection: sphereProjection(m.FileProjection),
|
||||
Duration: m.PhotoDuration,
|
||||
Width: m.FileWidth,
|
||||
Height: m.FileHeight,
|
||||
|
|
|
|||
|
|
@ -20,3 +20,11 @@ const (
|
|||
DefaultMapAudio = "0:a:0?"
|
||||
DefaultMapMetadata = "0"
|
||||
)
|
||||
|
||||
// Field-of-view limits in degrees for the v360 fisheye dewarp filter. The default matches the
|
||||
// overlapping lenses of typical Insta360 X-series cameras.
|
||||
const (
|
||||
MinFisheyeFov = 90
|
||||
DefaultFisheyeFov = 204
|
||||
MaxFisheyeFov = 360
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Options struct {
|
|||
Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options
|
||||
MovFlags string // FFmpeg "-movflags" value for the MP4 muxer (e.g. "use_metadata_tags+faststart"). See https://ffmpeg.org/ffmpeg-formats.html#Options-12
|
||||
VideoTag string // FFmpeg "-tag:v" override (e.g. "hvc1" for HEVC in MP4/MOV containers)
|
||||
V360 string // Optional FFmpeg "v360" filter applied before scaling, e.g. to dewarp dual-fisheye 360° video to equirectangular. See https://ffmpeg.org/ffmpeg-filters.html#v360
|
||||
Title string
|
||||
Description string
|
||||
Comment string
|
||||
|
|
@ -120,15 +121,22 @@ func NewPreviewImageOptions(ffmpegBin string, videoDuration time.Duration) *Opti
|
|||
|
||||
// VideoFilter returns the FFmpeg video filter string based on the size limit in pixels and the pixel format.
|
||||
func (o *Options) VideoFilter(format PixelFormat) string {
|
||||
// prefix is an optional geometry filter (e.g. a v360 dewarp) applied before scaling. It is a
|
||||
// software filter, so it is only expected on the CPU transcode path (not hardware pixel formats).
|
||||
var prefix string
|
||||
if o.V360 != "" {
|
||||
prefix = o.V360 + ","
|
||||
}
|
||||
|
||||
// scale specifies the FFmpeg downscale filter, see http://trac.ffmpeg.org/wiki/Scaling.
|
||||
switch format {
|
||||
case "":
|
||||
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))'", o.SizeLimit, o.SizeLimit)
|
||||
return prefix + fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))'", o.SizeLimit, o.SizeLimit)
|
||||
case FormatQSV:
|
||||
return fmt.Sprintf("scale_qsv=w='if(gte(iw,ih), min(%d, iw), -1)':h='if(gte(iw,ih), -1, min(%d, ih))':format=nv12", o.SizeLimit, o.SizeLimit)
|
||||
return prefix + fmt.Sprintf("scale_qsv=w='if(gte(iw,ih), min(%d, iw), -1)':h='if(gte(iw,ih), -1, min(%d, ih))':format=nv12", o.SizeLimit, o.SizeLimit)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))',format=%s", o.SizeLimit, o.SizeLimit, format)
|
||||
return prefix + fmt.Sprintf("scale='if(gte(iw,ih), min(%d, iw), -2):if(gte(iw,ih), -2, min(%d, ih))',format=%s", o.SizeLimit, o.SizeLimit, format)
|
||||
}
|
||||
|
||||
// QvQuality returns the video encoding quality as "-q:v" parameter string.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package encode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -48,4 +49,14 @@ func TestOptions_VideoFilter(t *testing.T) {
|
|||
assert.Contains(t, r, "format=rgb32")
|
||||
assert.Contains(t, r, "min(1500, iw)")
|
||||
})
|
||||
t.Run("V360", func(t *testing.T) {
|
||||
v360Opt := &Options{SizeLimit: 1500, V360: "v360=input=dfisheye:output=e"}
|
||||
r := v360Opt.VideoFilter("")
|
||||
assert.True(t, strings.HasPrefix(r, "v360=input=dfisheye:output=e,"))
|
||||
assert.Contains(t, r, "min(1500, iw)")
|
||||
})
|
||||
t.Run("NoV360", func(t *testing.T) {
|
||||
r := opt.VideoFilter("")
|
||||
assert.NotContains(t, r, "v360")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
137
internal/ffmpeg/v360.go
Normal file
137
internal/ffmpeg/v360.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
)
|
||||
|
||||
// V360DualFisheyeToEquirect returns the FFmpeg "v360" filter that dewarps a single-frame,
|
||||
// side-by-side dual-fisheye source (e.g. an Insta360 .insp/.insv original) to equirectangular.
|
||||
// fov is the per-lens field of view in degrees and roll is a verified spherical correction.
|
||||
func V360DualFisheyeToEquirect(fov, roll int) string {
|
||||
filter := fmt.Sprintf("v360=input=dfisheye:output=e:ih_fov=%d:iv_fov=%d", fov, fov)
|
||||
|
||||
if roll != 0 {
|
||||
filter += fmt.Sprintf(":roll=%d", roll)
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// V360FisheyeToEquirect returns the FFmpeg filter for a single circular fisheye source.
|
||||
func V360FisheyeToEquirect(fov, roll int) string {
|
||||
filter := fmt.Sprintf("v360=input=fisheye:output=e:ih_fov=%d:iv_fov=%d", fov, fov)
|
||||
|
||||
if roll != 0 {
|
||||
filter += fmt.Sprintf(":roll=%d", roll)
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// DewarpDualFisheyeToJpegCmd returns the command that dewarps a dual-fisheye still image
|
||||
// (e.g. an Insta360 .insp file, which FFmpeg decodes as JPEG) to an equirectangular JPEG.
|
||||
func DewarpDualFisheyeToJpegCmd(inputName, jpegName string, fov, roll int, opt *encode.Options) *exec.Cmd {
|
||||
return DewarpFisheyeToJpegCmd(inputName, jpegName, V360DualFisheyeToEquirect(fov, roll), opt)
|
||||
}
|
||||
|
||||
// DewarpFisheyeToJpegCmd dewarps an image with the specified v360 filter to an equirectangular JPEG.
|
||||
func DewarpFisheyeToJpegCmd(inputName, jpegName, filter string, opt *encode.Options) *exec.Cmd {
|
||||
if opt.SizeLimit > 0 {
|
||||
scaled := *opt
|
||||
scaled.V360 = filter
|
||||
filter = scaled.VideoFilter("")
|
||||
}
|
||||
|
||||
// #nosec G204 -- paths and flags are created by the application, not user input.
|
||||
return exec.Command(
|
||||
opt.Bin,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-y",
|
||||
"-i", inputName, // input dual-fisheye image
|
||||
"-vf", filter, // dewarp to equirectangular
|
||||
"-frames:v", "1", // write a single frame
|
||||
jpegName, // output equirectangular JPEG
|
||||
)
|
||||
}
|
||||
|
||||
// DewarpDualFisheyePairToJpegCmd combines separate left and right lens frames and dewarps them to JPEG.
|
||||
func DewarpDualFisheyePairToJpegCmd(leftName, rightName, jpegName string, fov, roll int, opt *encode.Options) *exec.Cmd {
|
||||
v360Filter := V360DualFisheyeToEquirect(fov, roll)
|
||||
if opt.SizeLimit > 0 {
|
||||
scaled := *opt
|
||||
scaled.V360 = v360Filter
|
||||
v360Filter = scaled.VideoFilter("")
|
||||
}
|
||||
filter := fmt.Sprintf("[0:v:0][1:v:0]hstack=inputs=2:shortest=1,%s[v]", v360Filter)
|
||||
|
||||
// #nosec G204 -- paths and flags are created by the application, not user input.
|
||||
return exec.Command(
|
||||
opt.Bin,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-y",
|
||||
"-i", leftName,
|
||||
"-i", rightName,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[v]",
|
||||
"-frames:v", "1",
|
||||
jpegName,
|
||||
)
|
||||
}
|
||||
|
||||
// DewarpStackedDualFisheyeToJpegCmd rearranges vertically stacked lens frames before dewarping.
|
||||
func DewarpStackedDualFisheyeToJpegCmd(inputName, jpegName string, fov, roll int, opt *encode.Options) *exec.Cmd {
|
||||
v360Filter := V360DualFisheyeToEquirect(fov, roll)
|
||||
if opt.SizeLimit > 0 {
|
||||
scaled := *opt
|
||||
scaled.V360 = v360Filter
|
||||
v360Filter = scaled.VideoFilter("")
|
||||
}
|
||||
filter := fmt.Sprintf("[0:v:0]crop=iw:ih/2:0:0[top];[0:v:0]crop=iw:ih/2:0:ih/2[bottom];[top][bottom]hstack=inputs=2:shortest=1,%s[v]", v360Filter)
|
||||
|
||||
// #nosec G204 -- paths and flags are created by the application, not user input.
|
||||
return exec.Command(
|
||||
opt.Bin,
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-y",
|
||||
"-i", inputName,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[v]",
|
||||
"-frames:v", "1",
|
||||
jpegName,
|
||||
)
|
||||
}
|
||||
|
||||
// DewarpDualFisheyePairToAvcCmd combines separate lens videos and encodes an equirectangular AVC derivative.
|
||||
func DewarpDualFisheyePairToAvcCmd(leftName, rightName, avcName string, opt encode.Options) *exec.Cmd {
|
||||
filter := fmt.Sprintf("[0:v:0][1:v:0]hstack=inputs=2:shortest=1,%s[v]", opt.VideoFilter(encode.FormatYUV420P))
|
||||
|
||||
// #nosec G204 -- paths and flags are created by the application, not user input.
|
||||
return exec.Command(
|
||||
opt.Bin,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-strict", "-2",
|
||||
"-i", leftName,
|
||||
"-i", rightName,
|
||||
"-filter_complex", filter,
|
||||
"-map", "[v]",
|
||||
"-map", opt.MapAudio,
|
||||
"-ignore_unknown",
|
||||
"-c:v", opt.Encoder.String(),
|
||||
"-c:a", "aac",
|
||||
"-preset", opt.Preset,
|
||||
"-max_muxing_queue_size", "1024",
|
||||
"-crf", opt.CrfQuality(),
|
||||
"-f", "mp4",
|
||||
"-movflags", opt.MovFlags,
|
||||
"-map_metadata", opt.MapMetadata,
|
||||
"-shortest",
|
||||
avcName,
|
||||
)
|
||||
}
|
||||
86
internal/ffmpeg/v360_test.go
Normal file
86
internal/ffmpeg/v360_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package ffmpeg
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestV360DualFisheyeToEquirect(t *testing.T) {
|
||||
assert.Equal(t, "v360=input=dfisheye:output=e:ih_fov=204:iv_fov=204", V360DualFisheyeToEquirect(204, 0))
|
||||
assert.Equal(t, "v360=input=dfisheye:output=e:ih_fov=190:iv_fov=190:roll=180", V360DualFisheyeToEquirect(190, 180))
|
||||
}
|
||||
|
||||
func TestV360FisheyeToEquirect(t *testing.T) {
|
||||
assert.Equal(t, "v360=input=fisheye:output=e:ih_fov=204:iv_fov=204", V360FisheyeToEquirect(204, 0))
|
||||
assert.Equal(t, "v360=input=fisheye:output=e:ih_fov=190:iv_fov=190:roll=90", V360FisheyeToEquirect(190, 90))
|
||||
}
|
||||
|
||||
func TestDewarpDualFisheyeToJpegCmd(t *testing.T) {
|
||||
opt := &encode.Options{Bin: "/usr/bin/ffmpeg"}
|
||||
|
||||
srcName := fs.Abs("./testdata/dualfisheye.insp")
|
||||
destName := fs.Abs("./testdata/dualfisheye.jpg")
|
||||
|
||||
cmd := DewarpDualFisheyeToJpegCmd(srcName, destName, 190, 180, opt)
|
||||
|
||||
cmdStr := cmd.String()
|
||||
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
|
||||
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
|
||||
|
||||
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -loglevel error -y -i SRC -vf "+V360DualFisheyeToEquirect(190, 180)+" -frames:v 1 DEST", cmdStr)
|
||||
}
|
||||
|
||||
// Negative: ffmpeg binary is missing; command execution should error immediately.
|
||||
func TestDewarpDualFisheyeToJpegCmd_MissingBinary(t *testing.T) {
|
||||
opt := &encode.Options{Bin: "/path/does/not/exist/ffmpeg"}
|
||||
srcName := fs.Abs("./testdata/dualfisheye.insp")
|
||||
destName := filepath.Join(t.TempDir(), "frame.jpg")
|
||||
cmd := DewarpDualFisheyeToJpegCmd(srcName, destName, 204, 0, opt)
|
||||
err := cmd.Run()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// TestDewarpDualFisheyePairToJpegCmd verifies canonical lens ordering and filter composition.
|
||||
func TestDewarpDualFisheyePairToJpegCmd(t *testing.T) {
|
||||
opt := &encode.Options{Bin: "/usr/bin/ffmpeg"}
|
||||
cmd := DewarpDualFisheyePairToJpegCmd("LEFT", "RIGHT", "DEST", 204, 180, opt)
|
||||
cmdStr := cmd.String()
|
||||
|
||||
assert.Contains(t, cmdStr, "-i LEFT -i RIGHT")
|
||||
assert.Contains(t, cmdStr, "[0:v:0][1:v:0]hstack=inputs=2:shortest=1,"+V360DualFisheyeToEquirect(204, 180)+"[v]")
|
||||
assert.Contains(t, cmdStr, "-map [v] -frames:v 1 DEST")
|
||||
}
|
||||
|
||||
// TestDewarpStackedDualFisheyeToJpegCmd verifies vertical lens splitting and horizontal stacking.
|
||||
func TestDewarpStackedDualFisheyeToJpegCmd(t *testing.T) {
|
||||
opt := &encode.Options{Bin: "/usr/bin/ffmpeg", SizeLimit: 15360}
|
||||
cmd := DewarpStackedDualFisheyeToJpegCmd("SOURCE", "DEST", 204, 180, opt)
|
||||
cmdStr := cmd.String()
|
||||
|
||||
assert.Contains(t, cmdStr, "[0:v:0]crop=iw:ih/2:0:0[top]")
|
||||
assert.Contains(t, cmdStr, "[0:v:0]crop=iw:ih/2:0:ih/2[bottom]")
|
||||
assert.Contains(t, cmdStr, "[top][bottom]hstack=inputs=2:shortest=1,v360=input=dfisheye:output=e")
|
||||
assert.Contains(t, cmdStr, "roll=180")
|
||||
assert.Contains(t, cmdStr, "min(15360, iw)")
|
||||
assert.Contains(t, cmdStr, "-map [v] -frames:v 1 DEST")
|
||||
}
|
||||
|
||||
// TestDewarpDualFisheyePairToAvcCmd verifies video, audio, and metadata mapping for paired lenses.
|
||||
func TestDewarpDualFisheyePairToAvcCmd(t *testing.T) {
|
||||
opt := encode.NewVideoOptions("/usr/bin/ffmpeg", encode.SoftwareAvc, 1920, 23, "fast", "", "", "")
|
||||
opt.V360 = V360DualFisheyeToEquirect(204, 180)
|
||||
cmd := DewarpDualFisheyePairToAvcCmd("LEFT", "RIGHT", "DEST", opt)
|
||||
cmdStr := cmd.String()
|
||||
|
||||
assert.Contains(t, cmdStr, "-i LEFT -i RIGHT")
|
||||
assert.Contains(t, cmdStr, "[0:v:0][1:v:0]hstack=inputs=2:shortest=1,v360=input=dfisheye:output=e")
|
||||
assert.Contains(t, cmdStr, "-map [v] -map 0:a:0?")
|
||||
assert.Contains(t, cmdStr, "-c:v libx264")
|
||||
assert.Contains(t, cmdStr, "-map_metadata 0 -shortest DEST")
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ type SearchPhotos struct {
|
|||
Scan string `form:"scan" example:"scan:true scan:false" notes:"Finds scanned photos and documents"`
|
||||
Mp string `form:"mp" example:"mp:3-6" notes:"Resolution in Megapixels (MP)"`
|
||||
Panorama bool `form:"panorama" notes:"Finds panorama pictures only (aspect ratio 1.9:1 or more)"`
|
||||
Fisheye bool `form:"fisheye" notes:"Finds fisheye 360° originals (e.g. Insta360 .insv/.insp)"`
|
||||
Portrait bool `form:"portrait" notes:"Finds portrait pictures only"`
|
||||
Landscape bool `form:"landscape" notes:"Finds landscape pictures only"`
|
||||
Square bool `form:"square" notes:"Finds square pictures only (aspect ratio 1:1)"`
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type SearchPhotosGeo struct {
|
|||
Scan string `form:"scan" example:"scan:true scan:false" notes:"Finds scanned photos and documents"`
|
||||
Mp string `form:"mp" example:"mp:3-6" notes:"Resolution in Megapixels (MP)"`
|
||||
Panorama bool `form:"panorama" notes:"Finds panorama pictures only (aspect ratio 1.9:1 or more)"`
|
||||
Fisheye bool `form:"fisheye" notes:"Finds fisheye 360° originals (e.g. Insta360 .insv/.insp)"`
|
||||
Portrait bool `form:"portrait" notes:"Finds portrait pictures only"`
|
||||
Landscape bool `form:"landscape" notes:"Finds landscape pictures only"`
|
||||
Square bool `form:"square" notes:"Finds square pictures only (aspect ratio 1:1)"`
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// ConvertCmd represents a command to be executed for converting a MediaFile.
|
||||
|
|
@ -14,6 +15,7 @@ type ConvertCmd struct {
|
|||
Orientation media.Orientation
|
||||
VerifyImage bool
|
||||
RejectStderr []string
|
||||
Projection projection.Type
|
||||
}
|
||||
|
||||
// String returns the conversion command as string e.g. for logging.
|
||||
|
|
@ -43,6 +45,13 @@ func (c *ConvertCmd) WithImageVerification() *ConvertCmd {
|
|||
return c
|
||||
}
|
||||
|
||||
// WithProjection records the visual projection of the command's output so it can be written to
|
||||
// the generated file after a successful conversion, e.g. to tag a dewarped derivative equirectangular.
|
||||
func (c *ConvertCmd) WithProjection(p projection.Type) *ConvertCmd {
|
||||
c.Projection = p
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStderrRejection rejects the command's output when its stderr contains any of the given
|
||||
// substrings, even on a zero exit code, so the loop tries the next converter.
|
||||
func (c *ConvertCmd) WithStderrRejection(patterns ...string) *ConvertCmd {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -12,6 +13,8 @@ import (
|
|||
"github.com/gabriel-vasile/mimetype"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
|
|
@ -19,6 +22,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/log/status"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// ToImage converts a media file to a directly supported image file format.
|
||||
|
|
@ -80,6 +84,7 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
|
|||
|
||||
fileName := f.RelName(w.conf.OriginalsPath())
|
||||
fileOrientation := media.KeepOrientation
|
||||
fileProjection := projection.Unknown
|
||||
xmpName := fs.SidecarXMP.Find(f.FileName(), false)
|
||||
|
||||
// Publish file conversion event.
|
||||
|
|
@ -215,6 +220,7 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
|
|||
|
||||
log.Infof("convert: %s created in %s (%s)", clean.Log(filepath.Base(imageName)), time.Since(start), filepath.Base(cmd.Path))
|
||||
fileOrientation = c.Orientation
|
||||
fileProjection = c.Projection
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -235,5 +241,122 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
|
|||
}
|
||||
}
|
||||
|
||||
// Fisheye 360° DNGs were developed to JPEG above; dewarp that JPEG to equirectangular now, since
|
||||
// FFmpeg cannot develop RAW itself. The developed frame must be a verified horizontal/vertical
|
||||
// dual-fisheye or single-fisheye layout. Best effort: a failure leaves the developed JPEG usable.
|
||||
if f.FisheyeDng() && result.IsJpeg() {
|
||||
developedProjection := projection.Unknown
|
||||
stacked := false
|
||||
switch {
|
||||
case result.DualFisheyeLayout():
|
||||
developedProjection = projection.DualFisheye
|
||||
case result.StackedDualFisheyeLayout():
|
||||
developedProjection = projection.DualFisheye
|
||||
stacked = true
|
||||
case result.FisheyeLayout():
|
||||
developedProjection = projection.Fisheye
|
||||
}
|
||||
|
||||
if developedProjection.Unknown() {
|
||||
log.Warnf("convert: unsupported developed fisheye layout in %s", clean.Log(result.RootRelName()))
|
||||
} else if dewarpErr := w.dewarpFileInPlace(result.FileName(), developedProjection, stacked, w.fisheyeFov(f), w.fisheyeRoll(f)); dewarpErr != nil {
|
||||
log.Warnf("convert: %s in %s (dewarp)", clean.Error(dewarpErr), clean.Log(result.RootRelName()))
|
||||
} else {
|
||||
fileProjection = projection.Equirectangular
|
||||
}
|
||||
}
|
||||
|
||||
// Tag a dewarped equirectangular derivative with GPano metadata and record its projection in an
|
||||
// ExifTool JSON sidecar, so the indexer reads the real projection through the normal metadata
|
||||
// pipeline (only files that were actually dewarped are tagged). The dewarp/GPano writes changed
|
||||
// the file, so reload it to refresh the cached size/hash regardless of the GPano write outcome.
|
||||
if fileProjection.Equal(projection.Equirectangular.String()) {
|
||||
result.SetVisualProjection(projection.Equirectangular)
|
||||
|
||||
if projErr := w.writeEquirectangularProjection(result.FileName()); projErr != nil {
|
||||
log.Warnf("convert: %s in %s (write projection)", clean.Error(projErr), clean.Log(result.RootRelName()))
|
||||
}
|
||||
|
||||
if reloaded, reloadErr := NewMediaFile(result.FileName()); reloadErr == nil {
|
||||
result = reloaded
|
||||
}
|
||||
|
||||
if _, jsonErr := w.ToJson(result, false); jsonErr != nil {
|
||||
log.Warnf("convert: %s in %s (create json)", clean.Error(jsonErr), clean.Log(result.RootRelName()))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// writeEquirectangularProjection tags the specified file as an equirectangular 360° image using
|
||||
// ExifTool GPano metadata, so the indexer records its projection and routes it to the sphere viewer.
|
||||
func (w *Convert) writeEquirectangularProjection(fileName string) error {
|
||||
if !w.conf.ExifToolEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// #nosec G204 -- arguments are built from validated config and file paths.
|
||||
cmd := exec.Command(w.conf.ExifToolBin(),
|
||||
"-q", "-overwrite_original",
|
||||
"-XMP-GPano:ProjectionType=equirectangular",
|
||||
"-XMP-GPano:UsePanoramaViewer=true",
|
||||
fileName,
|
||||
)
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()))
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if s := strings.TrimSpace(stderr.String()); s != "" {
|
||||
return errors.New(s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dewarpFileInPlace dewarps a fisheye image to equirectangular with the FFmpeg v360 filter, writing
|
||||
// to a temporary file and renaming it over the original because FFmpeg cannot read and write the
|
||||
// same path in one pass. Stacked inputs are rearranged before applying the spherical profile.
|
||||
func (w *Convert) dewarpFileInPlace(fileName string, inputProjection projection.Type, stacked bool, fov, roll int) error {
|
||||
if !w.conf.FFmpegEnabled() {
|
||||
return errors.New("ffmpeg is disabled")
|
||||
}
|
||||
|
||||
tmpName := fileName + ".dewarp.jpg"
|
||||
|
||||
// Always clean up the temp file: it is renamed over fileName on success (making this a no-op),
|
||||
// and removed on any error path so no stray "<name>.dewarp.jpg" is left to be indexed.
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
filter := ffmpeg.V360DualFisheyeToEquirect(fov, roll)
|
||||
if inputProjection.Equal(projection.Fisheye.String()) {
|
||||
filter = ffmpeg.V360FisheyeToEquirect(fov, roll)
|
||||
}
|
||||
|
||||
opt := &encode.Options{Bin: w.conf.FFmpegBin(), SizeLimit: min(w.conf.JpegSize(), 15360)}
|
||||
cmd := ffmpeg.DewarpFisheyeToJpegCmd(fileName, tmpName, filter, opt)
|
||||
if stacked && inputProjection.Equal(projection.DualFisheye.String()) {
|
||||
cmd = ffmpeg.DewarpStackedDualFisheyeToJpegCmd(fileName, tmpName, fov, roll, opt)
|
||||
}
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()))
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if s := strings.TrimSpace(stderr.String()); s != "" {
|
||||
return errors.New(s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !fs.FileExistsNotEmpty(tmpName) {
|
||||
return errors.New("no output produced")
|
||||
}
|
||||
|
||||
return os.Rename(tmpName, fileName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/ffmpeg"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
"github.com/photoprism/photoprism/internal/raw"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// JpegConvertCmds returns the supported commands for converting a MediaFile to JPEG, sorted by priority.
|
||||
|
|
@ -24,6 +25,29 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
|
|||
maxSize := strconv.Itoa(w.conf.JpegSize())
|
||||
rawEnabled := w.conf.RawEnabled()
|
||||
|
||||
// Separate square lens videos are combined in canonical _00/_10 order before v360 dewarping.
|
||||
// Only the _00 file owns generated sidecars; the _10 lens and LRV proxy remain related originals.
|
||||
if capture := FindInsta360Capture(f); capture != nil && capture.ValidPair() && capture.Left.FileName() == f.FileName() && w.conf.FFmpegEnabled() && w.FFmpegAllowed(f) {
|
||||
result = append(result, NewConvertCmd(
|
||||
ffmpeg.DewarpDualFisheyePairToJpegCmd(capture.Left.FileName(), capture.Right.FileName(), jpegName, w.fisheyeFov(f), w.fisheyeRoll(f), &encode.Options{Bin: w.conf.FFmpegBin(), SizeLimit: min(w.conf.JpegSize(), 15360)})).
|
||||
WithImageVerification().
|
||||
WithProjection(projection.Equirectangular),
|
||||
)
|
||||
}
|
||||
|
||||
// Dewarp Insta360 dual-fisheye originals to an equirectangular JPEG via the FFmpeg v360 filter,
|
||||
// so thumbnails and the sphere viewer show corrected pixels. This covers .insp photos as well as
|
||||
// the cover/poster frame extracted from .insv videos. Gated on a side-by-side ~2:1 layout so
|
||||
// X3/X4 per-lens and single-lens sources are left as normal renders; if the dewarp fails, the
|
||||
// loop falls back to a normal render too.
|
||||
if f.DualFisheye() && f.DualFisheyeLayout() && w.conf.FFmpegEnabled() && w.FFmpegAllowed(f) {
|
||||
result = append(result, NewConvertCmd(
|
||||
ffmpeg.DewarpDualFisheyeToJpegCmd(f.FileName(), jpegName, w.fisheyeFov(f), w.fisheyeRoll(f), &encode.Options{Bin: w.conf.FFmpegBin(), SizeLimit: min(w.conf.JpegSize(), 15360)})).
|
||||
WithImageVerification().
|
||||
WithProjection(projection.Equirectangular),
|
||||
)
|
||||
}
|
||||
|
||||
// On a Mac, use the Apple Scriptable image processing system to convert images to JPEG,
|
||||
// see https://ss64.com/osx/sips.html.
|
||||
if (f.IsRaw() && rawEnabled || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ package photoprism
|
|||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/raw"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
func TestConvert_ToImage(t *testing.T) {
|
||||
|
|
@ -341,6 +344,224 @@ func TestConvert_JpegConvertCmds(t *testing.T) {
|
|||
assert.True(t, found)
|
||||
}
|
||||
|
||||
// TestConvert_JpegConvertCmds_Insp verifies that an Insta360 .insp photo emits the FFmpeg v360
|
||||
// dewarp as its highest-priority command and tags the equirectangular output for the sphere viewer.
|
||||
func TestConvert_JpegConvertCmds_Insp(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
|
||||
if !cnf.FFmpegEnabled() {
|
||||
t.Skip("FFmpeg must be available to dewarp .insp files")
|
||||
}
|
||||
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
mediaFile, err := NewMediaFile("testdata/insta360.insp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmds, _, err := convert.JpegConvertCmds(mediaFile, "insta360.insp.jpg", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, cmds)
|
||||
first := cmds[0]
|
||||
assert.Contains(t, first.String(), "v360=input=dfisheye:output=e")
|
||||
assert.NotContains(t, first.String(), "roll=180")
|
||||
assert.True(t, first.Projection.Equal(projection.Equirectangular.String()))
|
||||
assert.True(t, first.VerifyImage)
|
||||
|
||||
oneRS, err := NewMediaFile(oneRSInspFixture(t, t.TempDir(), "camera.insp"))
|
||||
require.NoError(t, err)
|
||||
oneRSCmds, _, err := convert.JpegConvertCmds(oneRS, "camera.insp.jpg", "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, oneRSCmds)
|
||||
assert.Contains(t, oneRSCmds[0].String(), "v360=input=dfisheye:output=e:ih_fov=204:iv_fov=204:roll=180")
|
||||
}
|
||||
|
||||
// TestConvert_JpegConvertCmds_Insta360Pair verifies paired-lens poster generation.
|
||||
func TestConvert_JpegConvertCmds_Insta360Pair(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
if !cnf.FFmpegEnabled() {
|
||||
t.Skip("FFmpeg must be available to dewarp paired INSV files")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
rightName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmds, _, err := NewConvert(cnf).JpegConvertCmds(left, filepath.Join(dir, "poster.jpg"), "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, cmds)
|
||||
|
||||
assert.Contains(t, cmds[0].String(), "-i "+leftName+" -i "+rightName)
|
||||
assert.Contains(t, cmds[0].String(), "hstack=inputs=2:shortest=1,v360=input=dfisheye:output=e")
|
||||
assert.True(t, cmds[0].Projection.Equal(projection.Equirectangular.String()))
|
||||
}
|
||||
|
||||
// TestConvert_writeEquirectangularProjection verifies that the GPano equirectangular tag is
|
||||
// written so a dewarped derivative is self-describing to external tools.
|
||||
func TestConvert_writeEquirectangularProjection(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool must be available to write GPano metadata")
|
||||
}
|
||||
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
src, err := NewMediaFile("testdata/insta360.insp.jpg")
|
||||
require.NoError(t, err)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "equirect.jpg")
|
||||
require.NoError(t, src.Copy(dst, false))
|
||||
require.NoError(t, convert.writeEquirectangularProjection(dst))
|
||||
|
||||
// #nosec G204 -- arguments are the configured ExifTool binary and a temp file path.
|
||||
out, err := exec.Command(cnf.ExifToolBin(), "-s3", "-XMP-GPano:ProjectionType", dst).Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "equirectangular", strings.TrimSpace(string(out)))
|
||||
|
||||
t.Run("ExifToolDisabled", func(t *testing.T) {
|
||||
cnf.Options().DisableExifTool = true
|
||||
t.Cleanup(func() { cnf.Options().DisableExifTool = false })
|
||||
// Returns nil (no-op) when ExifTool is unavailable, leaving the file untagged.
|
||||
assert.NoError(t, NewConvert(cnf).writeEquirectangularProjection(filepath.Join(t.TempDir(), "x.jpg")))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvert_dewarpFileInPlace(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
if !cnf.FFmpegEnabled() {
|
||||
t.Skip("FFmpeg must be available to dewarp")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dst := copyFixture(t, dir, "df.jpg", "testdata/insta360.insp") // 2:1 dual-fisheye JPEG.
|
||||
require.NoError(t, convert.dewarpFileInPlace(dst, projection.DualFisheye, false, 204, 0))
|
||||
assert.False(t, fs.FileExists(dst+".dewarp.jpg"), "temp file must not leak")
|
||||
out, err := NewMediaFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 2.0, float64(out.AspectRatio()), 0.2) // equirectangular output is ~2:1.
|
||||
})
|
||||
t.Run("SingleFisheye", func(t *testing.T) {
|
||||
if !cnf.FFmpegEnabled() {
|
||||
t.Skip("FFmpeg must be available to dewarp")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dst := copyFixture(t, dir, "fisheye.jpg", "testdata/flash.jpg")
|
||||
require.NoError(t, convert.dewarpFileInPlace(dst, projection.Fisheye, false, 204, 0))
|
||||
out, err := NewMediaFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 2.0, float64(out.AspectRatio()), 0.2)
|
||||
})
|
||||
t.Run("StackedDualFisheye", func(t *testing.T) {
|
||||
if !cnf.FFmpegEnabled() {
|
||||
t.Skip("FFmpeg must be available to dewarp")
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "stacked.jpg")
|
||||
// #nosec G204 -- arguments are the configured FFmpeg binary, a fixture, and a temp path.
|
||||
cmd := exec.Command(cnf.FFmpegBin(), "-hide_banner", "-loglevel", "error", "-y", "-i", "testdata/flash.jpg", "-filter_complex", "[0:v][0:v]vstack=inputs=2[v]", "-map", "[v]", dst)
|
||||
require.NoError(t, cmd.Run())
|
||||
require.NoError(t, convert.dewarpFileInPlace(dst, projection.DualFisheye, true, 204, 180))
|
||||
out, err := NewMediaFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 2.0, float64(out.AspectRatio()), 0.2)
|
||||
})
|
||||
t.Run("FFmpegDisabled", func(t *testing.T) {
|
||||
cnf.Options().DisableFFmpeg = true
|
||||
t.Cleanup(func() { cnf.Options().DisableFFmpeg = false })
|
||||
err := NewConvert(cnf).dewarpFileInPlace(filepath.Join(t.TempDir(), "x.jpg"), projection.DualFisheye, false, 204, 0)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvert_fisheyeFov(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
cnf.Options().FFmpegFisheyeFov = 190
|
||||
t.Cleanup(func() { cnf.Options().FFmpegFisheyeFov = 0 })
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
t.Run("NilFallsBackToConfig", func(t *testing.T) {
|
||||
assert.Equal(t, 190, convert.fisheyeFov(nil))
|
||||
})
|
||||
t.Run("NoCameraFallsBackToConfig", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 190, convert.fisheyeFov(f))
|
||||
})
|
||||
t.Run("PerCamera", func(t *testing.T) {
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool must be available")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
f, err := NewMediaFile(dngFixture(t, dir, "insta360.dng", true)) // Make=Insta360, Model=Insta360 X4.
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 204, convert.fisheyeFov(f))
|
||||
})
|
||||
t.Run("OneRSInspMetadata", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInspFixture(t, t.TempDir(), "camera.insp"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 204, convert.fisheyeFov(f))
|
||||
})
|
||||
t.Run("OneRSInsvTrailer", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 204, convert.fisheyeFov(f))
|
||||
})
|
||||
}
|
||||
|
||||
// TestConvert_fisheyeRoll verifies that only supported OneRS fisheye originals are corrected.
|
||||
func TestConvert_fisheyeRoll(t *testing.T) {
|
||||
cnf := config.TestConfig()
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
assert.Equal(t, 0, convert.fisheyeRoll(nil))
|
||||
})
|
||||
t.Run("UnknownInsp", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, convert.fisheyeRoll(f))
|
||||
})
|
||||
t.Run("OneRSInsv", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 180, convert.fisheyeRoll(f))
|
||||
})
|
||||
t.Run("OneRSSquareInsv", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
require.NoError(t, err)
|
||||
f.width = 3072
|
||||
f.height = 3072
|
||||
assert.Equal(t, 0, convert.fisheyeRoll(f))
|
||||
})
|
||||
t.Run("Insta360Dng", func(t *testing.T) {
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool must be available")
|
||||
}
|
||||
f, err := NewMediaFile(dngFixture(t, t.TempDir(), "insta360.dng", true))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, convert.fisheyeRoll(f))
|
||||
})
|
||||
t.Run("OneRSDng", func(t *testing.T) {
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool must be available")
|
||||
}
|
||||
dst := dngFixture(t, t.TempDir(), "insta360.dng", true)
|
||||
// #nosec G204 -- arguments are the configured ExifTool binary and a temp file path.
|
||||
require.NoError(t, exec.Command(cnf.ExifToolBin(), "-q", "-overwrite_original", "-Make=Arashi Vision", "-Model=Insta360 OneRS", dst).Run())
|
||||
f, err := NewMediaFile(dst)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 180, convert.fisheyeRoll(f))
|
||||
})
|
||||
}
|
||||
|
||||
// TestConvert_JpegConvertCmds_RawEmbeddedPreview verifies that RAW inputs emit
|
||||
// ExifTool embedded-preview extraction commands (largest-first) ordered after the
|
||||
// RAW developers (Darktable and RawTherapee), so an unsupported camera falls back
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg"
|
||||
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
|
||||
|
|
@ -17,6 +18,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/fs/disk"
|
||||
"github.com/photoprism/photoprism/pkg/log/status"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// ToAvc converts a single video file to MPEG-4 AVC.
|
||||
|
|
@ -26,6 +28,12 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
|
|||
return nil, fmt.Errorf("convert: no media file provided for processing - you may have found a bug")
|
||||
}
|
||||
|
||||
// Normalize every member of a complete Insta360 capture to its canonical left lens so manual
|
||||
// conversion, background conversion, and playback all reuse one equirectangular AVC sidecar.
|
||||
if capture := FindInsta360Capture(f); capture != nil && capture.ValidPair() {
|
||||
f = capture.Left
|
||||
}
|
||||
|
||||
// Sanitized relative filename for use in logs.
|
||||
logFileName := clean.Log(f.RootRelName())
|
||||
|
||||
|
|
@ -75,7 +83,7 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
|
|||
// Return the AVC-encoded video file if it already exists.
|
||||
if mediaFile == nil || err != nil {
|
||||
// Do nothing.
|
||||
} else if mediaFile.IsVideo() {
|
||||
} else if mediaFile.IsVideo() && (!force || !mediaFile.InSidecar()) {
|
||||
// Return existing AVC file.
|
||||
log.Debugf("convert: %s has already been transcoded to MPEG-4 AVC", logFileName)
|
||||
return mediaFile, nil
|
||||
|
|
@ -184,8 +192,14 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
|
|||
// Log filename and transcoding time.
|
||||
log.Infof("%s: created %s [%s]", encoder, filepath.Base(avcName), time.Since(start))
|
||||
|
||||
// Return AVC media file.
|
||||
return NewMediaFile(avcName)
|
||||
// Return AVC media file and keep the successful dewarp projection available to the indexer even
|
||||
// when ExifTool is disabled. Later reindexes infer the same value from source and sidecar paths.
|
||||
avcFile, avcErr := NewMediaFile(avcName)
|
||||
if avcErr == nil && f.DewarpableInsv() {
|
||||
avcFile.SetVisualProjection(projection.Equirectangular)
|
||||
}
|
||||
|
||||
return avcFile, avcErr
|
||||
}
|
||||
|
||||
// TranscodeToAvcCmd returns the command for converting video files to MPEG-4 AVC.
|
||||
|
|
@ -206,13 +220,74 @@ func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode
|
|||
return exec.Command(w.conf.ImageMagickBin(), f.FileName(), avcName), false, nil
|
||||
}
|
||||
|
||||
// Complete separate-lens captures are combined before dewarping. Single-file INSV originals are
|
||||
// dewarped only when their decoded frame is already a side-by-side ~2:1 dual-fisheye layout.
|
||||
capture := FindInsta360Capture(f)
|
||||
dewarpPair := capture != nil && capture.ValidPair() && capture.Left.FileName() == f.FileName()
|
||||
dewarp := dewarpPair || f.IsInsv() && f.DualFisheyeLayout()
|
||||
|
||||
if dewarp {
|
||||
encoder = encode.SoftwareAvc
|
||||
}
|
||||
|
||||
// Use FFmpeg to transcode all other media files to AVC.
|
||||
var opt encode.Options
|
||||
if opt, err = w.conf.FFmpegOptions(encoder, w.AvcBitrate(f)); err != nil {
|
||||
return nil, false, fmt.Errorf("convert: failed to transcode %s (%s)", clean.Log(f.BaseName()), err)
|
||||
} else {
|
||||
return ffmpeg.TranscodeCmd(fileName, avcName, opt)
|
||||
}
|
||||
|
||||
if dewarp {
|
||||
opt.V360 = ffmpeg.V360DualFisheyeToEquirect(w.fisheyeFov(f), w.fisheyeRoll(f))
|
||||
}
|
||||
|
||||
if dewarpPair {
|
||||
return ffmpeg.DewarpDualFisheyePairToAvcCmd(capture.Left.FileName(), capture.Right.FileName(), avcName, opt), true, nil
|
||||
}
|
||||
|
||||
return ffmpeg.TranscodeCmd(fileName, avcName, opt)
|
||||
}
|
||||
|
||||
// fisheyeFov returns the v360 dewarp field of view in degrees for the given fisheye 360° file,
|
||||
// preferring a per-camera default and falling back to the configured FFmpegFisheyeFov.
|
||||
func (w *Convert) fisheyeFov(f *MediaFile) int {
|
||||
if f != nil {
|
||||
model := f.CameraModel()
|
||||
|
||||
if model == "" && f.DualFisheye() {
|
||||
model = f.Insta360CameraModel()
|
||||
}
|
||||
|
||||
if fov := entity.CameraFisheyeFov(f.CameraMake(), model); fov > 0 {
|
||||
return fov
|
||||
}
|
||||
}
|
||||
|
||||
return w.conf.FFmpegFisheyeFov()
|
||||
}
|
||||
|
||||
// fisheyeRoll returns a verified spherical roll correction for a compatible Insta360 original.
|
||||
func (w *Convert) fisheyeRoll(f *MediaFile) int {
|
||||
if f == nil || !f.DualFisheye() && !f.FisheyeDng() {
|
||||
return 0
|
||||
}
|
||||
|
||||
if capture := FindInsta360Capture(f); capture != nil && capture.ValidPair() {
|
||||
f = capture.Left
|
||||
} else if f.DualFisheye() && !f.DualFisheyeLayout() {
|
||||
return 0
|
||||
}
|
||||
|
||||
model := f.Insta360CameraModel()
|
||||
if f.FisheyeDng() {
|
||||
model = f.CameraModel()
|
||||
}
|
||||
roll := entity.CameraFisheyeRoll(f.CameraMake(), model)
|
||||
|
||||
if roll != 0 {
|
||||
log.Debugf("convert: using v360 profile insta360-one-rs (roll %d) for %s", roll, clean.Log(f.BaseName()))
|
||||
}
|
||||
|
||||
return roll
|
||||
}
|
||||
|
||||
// AvcBitrate returns the ideal AVC encoding bitrate in megabits per second.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package photoprism
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
|
@ -41,6 +43,18 @@ func TestConvert_ToAvc(t *testing.T) {
|
|||
|
||||
t.Logf("video metadata: %+v", avcFile.MetaData())
|
||||
|
||||
oldTime := time.Unix(1, 0)
|
||||
assert.NoError(t, os.Chtimes(outputName, oldTime, oldTime))
|
||||
|
||||
avcFile, err = convert.ToAvc(mf, encode.SoftwareAvc, false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(avcFile.FileName())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.ModTime().After(oldTime), "forced conversion should replace the sidecar")
|
||||
|
||||
_ = os.Remove(outputName)
|
||||
})
|
||||
t.Run("Jpg", func(t *testing.T) {
|
||||
|
|
@ -188,4 +202,76 @@ func TestConvert_TranscodeToAvcCmd(t *testing.T) {
|
|||
assert.Contains(t, r.Args, webpName)
|
||||
assert.Contains(t, r.Args, avcName)
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
mf, err := NewMediaFile("testdata/insta360.insv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A hardware encoder is requested, but .insv must be forced onto the software v360 path.
|
||||
r, _, err := convert.TranscodeToAvcCmd(mf, "insta360.avc", encode.Encoder("intel"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := strings.Join(r.Args, " ")
|
||||
assert.Contains(t, r.Path, "ffmpeg")
|
||||
assert.Contains(t, args, "v360=input=dfisheye:output=e")
|
||||
assert.NotContains(t, args, "roll=180")
|
||||
assert.Contains(t, args, "libx264")
|
||||
})
|
||||
t.Run("OneRSInsv", func(t *testing.T) {
|
||||
mf, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _, err := convert.TranscodeToAvcCmd(mf, "camera.avc", encode.SoftwareAvc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Contains(t, strings.Join(r.Args, " "), "v360=input=dfisheye:output=e:ih_fov=204:iv_fov=204:roll=180")
|
||||
})
|
||||
t.Run("OneRSSquareInsv", func(t *testing.T) {
|
||||
mf, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf.width = 3072
|
||||
mf.height = 3072
|
||||
r, _, err := convert.TranscodeToAvcCmd(mf, "camera.avc", encode.SoftwareAvc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.NotContains(t, strings.Join(r.Args, " "), "v360")
|
||||
})
|
||||
t.Run("Insta360SeparateLensPair", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
rightName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
mf, err := NewMediaFile(leftName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r, useMutex, err := convert.TranscodeToAvcCmd(mf, "camera.avc", encode.Encoder("intel"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
args := strings.Join(r.Args, " ")
|
||||
assert.True(t, useMutex)
|
||||
assert.Contains(t, args, "-i "+leftName+" -i "+rightName)
|
||||
assert.Contains(t, args, "hstack=inputs=2:shortest=1,v360=input=dfisheye:output=e")
|
||||
assert.Contains(t, args, "-map [v] -map 0:a:0?")
|
||||
assert.Contains(t, args, "libx264")
|
||||
})
|
||||
t.Run("Mp4NoV360", func(t *testing.T) {
|
||||
mf, err := NewMediaFile(filepath.Join(conf.SamplesPath(), "gopher-video.mp4"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _, err := convert.TranscodeToAvcCmd(mf, "gopher.avc", encode.SoftwareAvc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.NotContains(t, strings.Join(r.Args, " "), "v360")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ func ConvertWorker(jobs <-chan ConvertJob) {
|
|||
// f is the media file to be converted.
|
||||
f := job.file
|
||||
|
||||
// A complete Insta360 capture has one canonical _00 owner. Its _10 lens and LRV proxy are
|
||||
// preserved and indexed as related originals, but must not create duplicate sidecars.
|
||||
if capture := FindInsta360Capture(f); capture != nil && capture.ValidPair() && capture.Left.FileName() != f.FileName() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case f.IsAnimated():
|
||||
// Extract metadata.
|
||||
|
|
@ -64,7 +70,7 @@ func ConvertWorker(jobs <-chan ConvertJob) {
|
|||
}
|
||||
|
||||
// Transcode to MP4 AVC.
|
||||
if _, err := job.convert.ToAvc(f, job.convert.conf.FFmpegEncoder(), false, false); err != nil {
|
||||
if _, err := job.convert.ToAvc(f, job.convert.conf.FFmpegEncoder(), false, job.force); err != nil {
|
||||
handleErr(err, job)
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
164
internal/photoprism/index_insta360.go
Normal file
164
internal/photoprism/index_insta360.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/dsn"
|
||||
)
|
||||
|
||||
var insta360ReconcileMutex sync.Mutex
|
||||
|
||||
// reconcileInsta360Photos combines previously separate capture records during a forced reindex.
|
||||
func reconcileInsta360Photos(related RelatedFiles) error {
|
||||
if related.Main == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
capture := FindInsta360Capture(related.Main)
|
||||
if capture == nil || !capture.ValidPair() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileNames := make([]string, 0, 3)
|
||||
for _, file := range capture.Files() {
|
||||
fileNames = append(fileNames, file.RootRelName())
|
||||
}
|
||||
|
||||
insta360ReconcileMutex.Lock()
|
||||
defer insta360ReconcileMutex.Unlock()
|
||||
|
||||
var indexedFiles []entity.File
|
||||
if err := entity.UnscopedDb().Where("file_root = ? AND file_name IN (?)", entity.RootOriginals, fileNames).Find(&indexedFiles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
photoIDs := make([]uint, 0, len(indexedFiles))
|
||||
seen := make(map[uint]bool, len(indexedFiles))
|
||||
for _, file := range indexedFiles {
|
||||
if file.PhotoID > 0 && !seen[file.PhotoID] {
|
||||
seen[file.PhotoID] = true
|
||||
photoIDs = append(photoIDs, file.PhotoID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(photoIDs) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var photos entity.Photos
|
||||
if err := entity.UnscopedDb().Where("id IN (?)", photoIDs).Order("id ASC").Find(&photos).Error; err != nil {
|
||||
return err
|
||||
} else if len(photos) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
canonical := photos[0]
|
||||
favorite, private, panorama, allArchived := false, false, false, true
|
||||
title, titleSrc := canonical.PhotoTitle, canonical.TitleSrc
|
||||
caption, captionSrc := canonical.PhotoCaption, canonical.CaptionSrc
|
||||
|
||||
for _, photo := range photos {
|
||||
favorite = favorite || photo.PhotoFavorite
|
||||
private = private || photo.PhotoPrivate
|
||||
panorama = panorama || photo.PhotoPanorama
|
||||
allArchived = allArchived && photo.DeletedAt != nil
|
||||
|
||||
if photo.PhotoTitle != "" && (title == "" || entity.SrcPriority[photo.TitleSrc] > entity.SrcPriority[titleSrc]) {
|
||||
title, titleSrc = photo.PhotoTitle, photo.TitleSrc
|
||||
}
|
||||
if photo.PhotoCaption != "" && (caption == "" || entity.SrcPriority[photo.CaptionSrc] > entity.SrcPriority[captionSrc]) {
|
||||
caption, captionSrc = photo.PhotoCaption, photo.CaptionSrc
|
||||
}
|
||||
}
|
||||
|
||||
values := entity.Values{
|
||||
"photo_favorite": favorite,
|
||||
"photo_private": private,
|
||||
"photo_panorama": panorama,
|
||||
"photo_title": title,
|
||||
"title_src": titleSrc,
|
||||
"photo_caption": caption,
|
||||
"caption_src": captionSrc,
|
||||
}
|
||||
if allArchived {
|
||||
values["deleted_at"] = canonical.DeletedAt
|
||||
} else {
|
||||
values["deleted_at"] = nil
|
||||
}
|
||||
|
||||
tx := entity.UnscopedDb().Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
rollback := func(err error) error {
|
||||
if rollbackErr := tx.Rollback().Error; rollbackErr != nil {
|
||||
return errors.Join(err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&entity.Photo{}).Where("id = ?", canonical.ID).UpdateColumns(values).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
for _, duplicate := range photos[1:] {
|
||||
if err := tx.Model(&entity.File{}).Where("photo_id = ?", duplicate.ID).UpdateColumns(entity.Values{
|
||||
"photo_id": canonical.ID,
|
||||
"photo_uid": canonical.PhotoUID,
|
||||
"file_primary": false,
|
||||
}).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
var statements []string
|
||||
switch entity.DbDialect() {
|
||||
case dsn.DriverMySQL:
|
||||
statements = []string{
|
||||
"UPDATE IGNORE photos_keywords SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE IGNORE photos_labels SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE IGNORE photos_albums SET photo_uid = ? WHERE photo_uid = ?",
|
||||
}
|
||||
case dsn.DriverSQLite3:
|
||||
statements = []string{
|
||||
"UPDATE OR IGNORE photos_keywords SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE OR IGNORE photos_labels SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE OR IGNORE photos_albums SET photo_uid = ? WHERE photo_uid = ?",
|
||||
}
|
||||
default:
|
||||
return rollback(errors.New("unsupported database dialect"))
|
||||
}
|
||||
|
||||
if err := tx.Exec(statements[0], canonical.ID, duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec(statements[1], canonical.ID, duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec(statements[2], canonical.PhotoUID, duplicate.PhotoUID).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
if err := tx.Exec("DELETE FROM photos_keywords WHERE photo_id = ?", duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec("DELETE FROM photos_labels WHERE photo_id = ?", duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec("DELETE FROM photos_albums WHERE photo_uid = ?", duplicate.PhotoUID).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&entity.Photo{}).Where("id = ?", duplicate.ID).UpdateColumns(entity.Values{
|
||||
"photo_quality": -1,
|
||||
"deleted_at": entity.Now(),
|
||||
}).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entity.File{PhotoID: canonical.ID, PhotoUID: canonical.PhotoUID}.RegenerateIndex()
|
||||
return nil
|
||||
}
|
||||
89
internal/photoprism/index_insta360_test.go
Normal file
89
internal/photoprism/index_insta360_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// TestReconcileInsta360Photos verifies force-reindex state preservation and file reassignment.
|
||||
func TestReconcileInsta360Photos(t *testing.T) {
|
||||
t.Setenv("PHOTOPRISM_TEST_DSN", filepath.Join(t.TempDir(), "index-insta360-reconcile.db"))
|
||||
cfg := config.NewMinimalTestConfigWithDb("index-insta360-reconcile", filepath.Join(t.TempDir(), "storage"))
|
||||
oldCfg := Config()
|
||||
SetConfig(cfg)
|
||||
t.Cleanup(func() {
|
||||
SetConfig(oldCfg)
|
||||
oldCfg.RegisterDb()
|
||||
})
|
||||
|
||||
dir := filepath.Join(cfg.OriginalsPath(), "capture")
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
rightName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
proxyName := writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
related, err := left.RelatedFiles(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
photos := make([]entity.Photo, 3)
|
||||
for i := range photos {
|
||||
photos[i] = entity.NewPhoto(true)
|
||||
photos[i].PhotoTitle = ""
|
||||
require.NoError(t, photos[i].Create())
|
||||
}
|
||||
|
||||
archivedAt := entity.Now()
|
||||
photos[0].DeletedAt = &archivedAt
|
||||
photos[1].DeletedAt = &archivedAt
|
||||
photos[1].PhotoFavorite = true
|
||||
photos[2].PhotoCaption = "preserved manual caption"
|
||||
photos[2].CaptionSrc = entity.SrcManual
|
||||
for i := range photos {
|
||||
require.NoError(t, photos[i].Save())
|
||||
}
|
||||
|
||||
for i, fileName := range []string{leftName, rightName, proxyName} {
|
||||
mediaFile, mediaErr := NewMediaFile(fileName)
|
||||
require.NoError(t, mediaErr)
|
||||
file := entity.File{
|
||||
PhotoID: photos[i].ID,
|
||||
PhotoUID: photos[i].PhotoUID,
|
||||
FileName: mediaFile.RootRelName(),
|
||||
FileRoot: entity.RootOriginals,
|
||||
FileHash: mediaFile.Hash(),
|
||||
FileType: mediaFile.FileType().String(),
|
||||
MediaType: media.Video.String(),
|
||||
FileVideo: true,
|
||||
}
|
||||
require.NoError(t, file.Create())
|
||||
}
|
||||
|
||||
require.NoError(t, reconcileInsta360Photos(related))
|
||||
|
||||
var canonical entity.Photo
|
||||
require.NoError(t, entity.UnscopedDb().First(&canonical, "id = ?", photos[0].ID).Error)
|
||||
assert.True(t, canonical.PhotoFavorite)
|
||||
assert.Nil(t, canonical.DeletedAt)
|
||||
assert.Equal(t, "preserved manual caption", canonical.PhotoCaption)
|
||||
assert.Equal(t, entity.SrcManual, canonical.CaptionSrc)
|
||||
|
||||
var files []entity.File
|
||||
require.NoError(t, entity.UnscopedDb().Where("file_name IN (?)", []string{
|
||||
left.RootRelName(),
|
||||
RootRelName(rightName),
|
||||
RootRelName(proxyName),
|
||||
}).Find(&files).Error)
|
||||
require.Len(t, files, 3)
|
||||
for _, file := range files {
|
||||
assert.Equal(t, canonical.ID, file.PhotoID)
|
||||
assert.Equal(t, canonical.PhotoUID, file.PhotoUID)
|
||||
}
|
||||
}
|
||||
|
|
@ -43,8 +43,9 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
}
|
||||
|
||||
// Create JPEG sidecar for media files in other formats so that thumbnails can be created.
|
||||
if o.Convert && f.IsMedia() && !f.HasPreviewImage() {
|
||||
if img, imgErr := ind.convert.ToImage(f, false); imgErr != nil {
|
||||
forcePreview := forceDewarpPreview(f, o.Rescan)
|
||||
if o.Convert && f.IsMedia() && (!f.HasPreviewImage() || forcePreview) {
|
||||
if img, imgErr := ind.convert.ToImage(f, forcePreview); imgErr != nil {
|
||||
// Stop the run instead of masking a full disk as a generic preview error.
|
||||
if errors.Is(imgErr, status.ErrInsufficientStorage) {
|
||||
ind.abortInsufficientStorage()
|
||||
|
|
@ -84,6 +85,21 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
}
|
||||
}
|
||||
|
||||
// Generate the playable equirectangular AVC while the index worker is already running in the
|
||||
// background. This prevents the HTTP video endpoint from doing an expensive v360 conversion.
|
||||
if o.Convert && f.DewarpableInsv() {
|
||||
if avc, avcErr := ind.convert.ToAvc(f, ind.conf.FFmpegEncoder(), false, o.Rescan); errors.Is(avcErr, status.ErrInsufficientStorage) {
|
||||
ind.abortInsufficientStorage()
|
||||
result.Err = avcErr
|
||||
result.Status = IndexFailed
|
||||
return result
|
||||
} else if avcErr != nil {
|
||||
log.Warnf("index: could not create equirectangular video for %s (%s)", clean.Log(f.RootRelName()), avcErr)
|
||||
} else if avc != nil {
|
||||
related.Files = append(related.Files, avc)
|
||||
}
|
||||
}
|
||||
|
||||
// Index main MediaFile.
|
||||
exists := ind.files.Exists(f.RootRelName(), f.Root())
|
||||
result = ind.MediaFile(f, o, "", "")
|
||||
|
|
@ -108,3 +124,8 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
// forceDewarpPreview reports whether forced indexing should replace a recognized 360° preview.
|
||||
func forceDewarpPreview(f *MediaFile, rescan bool) bool {
|
||||
return rescan && f != nil && (f.DewarpableInsv() || f.IsInsp() && f.DualFisheyeLayout() || f.FisheyeDng())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/time/tz"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
|
|
@ -432,7 +433,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
if data := m.MetaData(); data.Error == nil {
|
||||
file.FileCodec = data.Codec
|
||||
file.SetMediaUTC(data.TakenAt)
|
||||
file.SetProjection(data.Projection)
|
||||
file.SetProjection(m.VisualProjection(data.Projection).String())
|
||||
file.SetHDR(data.IsHDR())
|
||||
file.SetColorProfile(data.ColorProfile)
|
||||
file.SetSoftware(data.Software)
|
||||
|
|
@ -596,11 +597,18 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
file.FilePortrait = m.Portrait()
|
||||
file.SetMediaUTC(data.TakenAt)
|
||||
file.SetPages(data.Pages)
|
||||
file.SetProjection(data.Projection)
|
||||
file.SetProjection(m.VisualProjection(data.Projection).String())
|
||||
file.SetHDR(data.IsHDR())
|
||||
file.SetColorProfile(data.ColorProfile)
|
||||
file.SetSoftware(data.Software)
|
||||
|
||||
// Fisheye 360° DNGs are dual-fisheye; detection reads make/model/lens/projection, so it
|
||||
// runs inside this metadata-ok block to avoid an extra MetaData() call and a projection
|
||||
// tag on a file whose EXIF failed (leaving 0x0 dimensions).
|
||||
if m.FisheyeDng() {
|
||||
file.SetProjection(m.FisheyeDngProjection().String())
|
||||
}
|
||||
|
||||
// Get video metadata from embedded file?
|
||||
if !m.IsHeic() || !data.HasVideoEmbedded {
|
||||
file.SetDuration(data.Duration)
|
||||
|
|
@ -650,6 +658,12 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
photo.SetMediaType(media.Vector, entity.SrcAuto)
|
||||
}
|
||||
}
|
||||
|
||||
// Insta360 .insp originals store dual-fisheye 360° content; record the projection regardless
|
||||
// of metadata errors (these files often lack EXIF) so the dewarped derivative routes correctly.
|
||||
if m.DualFisheye() {
|
||||
file.SetProjection(projection.DualFisheye.String())
|
||||
}
|
||||
case m.IsVector():
|
||||
if data := m.MetaData(); data.Error == nil {
|
||||
// Update basic metadata.
|
||||
|
|
@ -685,7 +699,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
file.FilePortrait = m.Portrait()
|
||||
file.SetMediaUTC(data.TakenAt)
|
||||
file.SetPages(data.Pages)
|
||||
file.SetProjection(data.Projection)
|
||||
file.SetProjection(m.VisualProjection(data.Projection).String())
|
||||
file.SetHDR(data.IsHDR())
|
||||
file.SetColorProfile(data.ColorProfile)
|
||||
file.SetSoftware(data.Software)
|
||||
|
|
@ -781,7 +795,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
file.SetDuration(data.Duration)
|
||||
file.SetFPS(data.FPS)
|
||||
file.SetFrames(data.Frames)
|
||||
file.SetProjection(data.Projection)
|
||||
file.SetProjection(m.VisualProjection(data.Projection).String())
|
||||
file.SetHDR(data.IsHDR())
|
||||
file.SetColorProfile(data.ColorProfile)
|
||||
file.SetSoftware(data.Software)
|
||||
|
|
@ -808,6 +822,12 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
|
|||
photo.SetMediaType(media.Video, entity.SrcAuto)
|
||||
}
|
||||
|
||||
// Insta360 .insv originals store dual-fisheye 360° content; record the projection regardless
|
||||
// of metadata errors (these files often lack EXIF) so the dewarped transcode routes correctly.
|
||||
if m.DualFisheye() {
|
||||
file.SetProjection(projection.DualFisheye.String())
|
||||
}
|
||||
|
||||
// Set the video dimensions from the primary image if it could not be determined from the video metadata.
|
||||
// If there is no primary image yet, File.UpdateVideoInfos() sets the fields in retrospect when there is one.
|
||||
if file.FileWidth == 0 && primaryFile.FileWidth > 0 {
|
||||
|
|
|
|||
|
|
@ -287,3 +287,53 @@ func TestIndex_IndexedFileOriginalName(t *testing.T) {
|
|||
assert.Equal(t, "indexed-original-name/renamed-photo.jpg", file2.FileName)
|
||||
assert.Empty(t, file2.OriginalName, "re-indexed renamed file must not pick up a stale OriginalName")
|
||||
}
|
||||
|
||||
// TestIndex_MediaFile_DualFisheye verifies that an Insta360 .insp original is recognized and
|
||||
// recorded with the dual-fisheye projection, which also flags the photo as a panorama.
|
||||
func TestIndex_MediaFile_DualFisheye(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
t.Setenv("PHOTOPRISM_TEST_DSN", filepath.Join(t.TempDir(), "index-dual-fisheye.db"))
|
||||
|
||||
cfg := config.NewMinimalTestConfigWithDb("index-dual-fisheye", filepath.Join(t.TempDir(), "storage"))
|
||||
|
||||
oldCfg := Config()
|
||||
SetConfig(cfg)
|
||||
|
||||
t.Cleanup(func() {
|
||||
SetConfig(oldCfg)
|
||||
oldCfg.RegisterDb()
|
||||
})
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
ind := NewIndex(cfg, convert, NewFiles(), NewPhotos())
|
||||
opt := IndexOptionsSingle(cfg)
|
||||
opt.Convert = false // exercise original detection without the ffmpeg dewarp
|
||||
|
||||
srcFile, err := NewMediaFile("testdata/insta360.insp")
|
||||
require.NoError(t, err)
|
||||
|
||||
dst := filepath.Join(cfg.OriginalsPath(), "insta360.insp")
|
||||
require.NoError(t, srcFile.Copy(dst, false))
|
||||
|
||||
mf, err := NewMediaFile(dst)
|
||||
require.NoError(t, err)
|
||||
hash := mf.Hash()
|
||||
|
||||
// The full index flow creates the ExifTool JSON before indexing; it also supplies the JPEG
|
||||
// dimensions the panorama flag depends on.
|
||||
require.NoError(t, mf.CreateExifToolJson(convert))
|
||||
|
||||
res := ind.MediaFile(mf, opt, "", "")
|
||||
require.True(t, res.Success())
|
||||
|
||||
var file entity.File
|
||||
require.NoError(t, entity.UnscopedDb().First(&file, "file_hash = ?", hash).Error)
|
||||
assert.Equal(t, "dual-fisheye", file.FileProjection)
|
||||
|
||||
var photo entity.Photo
|
||||
require.NoError(t, entity.UnscopedDb().First(&photo, "id = ?", file.PhotoID).Error)
|
||||
assert.True(t, photo.PhotoPanorama)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde
|
|||
return result
|
||||
}
|
||||
|
||||
// Forced reindexing upgrades captures that older versions stored as separate lens/proxy photos.
|
||||
if o.Rescan {
|
||||
if reconcileErr := reconcileInsta360Photos(related); reconcileErr != nil {
|
||||
log.Warnf("index: could not reconcile Insta360 capture %s (%s)", related.MainLogName(), reconcileErr)
|
||||
}
|
||||
}
|
||||
|
||||
done := make(map[string]bool)
|
||||
result = IndexMain(&related, ind, o)
|
||||
|
||||
|
|
@ -73,7 +80,7 @@ func IndexRelated(related RelatedFiles, ind *Index, o IndexOptions) (result Inde
|
|||
}
|
||||
|
||||
// Create JPEG sidecar for media files in other formats so that thumbnails can be created.
|
||||
if o.Convert && f.IsMedia() && !f.HasPreviewImage() {
|
||||
if o.Convert && f.IsMedia() && !f.InSidecar() && !f.HasPreviewImage() {
|
||||
// Try to create a preview image; if this fails, log and continue without failing the whole group.
|
||||
if img, imgErr := ind.convert.ToImage(f, false); imgErr != nil {
|
||||
// Stop the run instead of masking a full disk as a generic preview error.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
"github.com/photoprism/photoprism/pkg/media/video"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
|
@ -63,6 +64,9 @@ type MediaFile struct {
|
|||
metaOnce sync.Once
|
||||
videoInfo video.Info
|
||||
videoOnce sync.Once
|
||||
insta360Model string
|
||||
insta360Once sync.Once
|
||||
visualProjection projection.Type
|
||||
fileMutex sync.Mutex
|
||||
location *entity.Cell
|
||||
imageConfig *image.Config
|
||||
|
|
@ -905,6 +909,117 @@ func (m *MediaFile) IsDng() bool {
|
|||
return m.HasMimeType(header.ContentTypeDng)
|
||||
}
|
||||
|
||||
// IsInsp checks if the file is an Insta360 panoramic image (dual-fisheye JPEG).
|
||||
func (m *MediaFile) IsInsp() bool {
|
||||
return fs.FileType(m.fileName) == fs.ImageInsp
|
||||
}
|
||||
|
||||
// IsInsv checks if the file is an Insta360 video (dual-fisheye MP4 container).
|
||||
func (m *MediaFile) IsInsv() bool {
|
||||
return fs.FileType(m.fileName) == fs.VideoInsv
|
||||
}
|
||||
|
||||
// IsInsta360 checks if the file was recorded by an Insta360 camera, based on its maker metadata.
|
||||
// Older models (ONE, ONE X) report the vendor "Arashi Vision" with the model name carrying "Insta360",
|
||||
// while newer models report "Insta360" directly.
|
||||
func (m *MediaFile) IsInsta360() bool {
|
||||
if m.IsInsp() || m.IsInsv() {
|
||||
return true
|
||||
}
|
||||
|
||||
switch strings.ToLower(m.CameraMake()) {
|
||||
case "insta360", "arashi vision":
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.HasPrefix(strings.ToLower(m.CameraModel()), "insta360")
|
||||
}
|
||||
|
||||
// Insta360CameraModel returns the embedded model name for an Insta360 original.
|
||||
func (m *MediaFile) Insta360CameraModel() string {
|
||||
if m == nil || !m.IsInsp() && !m.IsInsv() {
|
||||
return ""
|
||||
}
|
||||
|
||||
if model := strings.TrimSpace(m.CameraModel()); model != "" {
|
||||
return model
|
||||
}
|
||||
|
||||
m.insta360Once.Do(func() {
|
||||
model, err := media.Insta360CameraModelFile(m.FileName())
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("media: %s in %s (read Insta360 camera model)", clean.Error(err), clean.Log(m.BaseName()))
|
||||
return
|
||||
}
|
||||
|
||||
m.insta360Model = model
|
||||
})
|
||||
|
||||
return m.insta360Model
|
||||
}
|
||||
|
||||
// DualFisheye checks if the file stores dual-fisheye 360° content that must be dewarped to
|
||||
// equirectangular before it can be shown in the sphere viewer. Insta360 .insp/.insv originals
|
||||
// always do; the extension is authoritative for these proprietary formats.
|
||||
func (m *MediaFile) DualFisheye() bool {
|
||||
return m.IsInsp() || m.IsInsv()
|
||||
}
|
||||
|
||||
// FisheyeDng checks if the file is a single- or dual-fisheye DNG that must be developed and dewarped.
|
||||
func (m *MediaFile) FisheyeDng() bool {
|
||||
return m.FisheyeDngProjection().Fisheye()
|
||||
}
|
||||
|
||||
// FisheyeDngProjection returns the conservatively detected fisheye geometry of a DNG original.
|
||||
func (m *MediaFile) FisheyeDngProjection() projection.Type {
|
||||
if !m.IsDng() {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
if detected := projection.New(m.MetaData().Projection); detected.Fisheye() {
|
||||
return detected
|
||||
}
|
||||
|
||||
// Known 360 camera vendors identify a fisheye original, while the raster aspect distinguishes
|
||||
// horizontal or vertical dual-lens frames from a single circular lens. Other geometry stays
|
||||
// single fisheye and is verified again after RAW development before v360 is allowed to run.
|
||||
if m.IsInsta360() || strings.Contains(strings.ToLower(m.CameraModel()), "theta") {
|
||||
if m.DualFisheyeLayout() || m.StackedDualFisheyeLayout() {
|
||||
return projection.DualFisheye
|
||||
}
|
||||
|
||||
return projection.Fisheye
|
||||
}
|
||||
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
// DualFisheyeLayout reports whether the frame is compatible with the side-by-side dual-fisheye
|
||||
// input the FFmpeg "v360=input=dfisheye" dewarp expects. A known aspect ratio must be close to 2:1;
|
||||
// an unknown aspect (0) is treated as compatible so the extension-authoritative .insp/.insv path
|
||||
// still dewarps. This rejects Insta360 X3/X4 per-lens streams and single-lens/rectilinear sources
|
||||
// (whose real dimensions are known and clearly not 2:1) rather than distorting them.
|
||||
func (m *MediaFile) DualFisheyeLayout() bool {
|
||||
r := float64(m.AspectRatio())
|
||||
|
||||
return r <= 0 || math.Abs(r-2.0) <= 0.2
|
||||
}
|
||||
|
||||
// StackedDualFisheyeLayout reports whether two square fisheye frames are stacked vertically.
|
||||
func (m *MediaFile) StackedDualFisheyeLayout() bool {
|
||||
r := float64(m.AspectRatio())
|
||||
|
||||
return r > 0 && math.Abs(r-0.5) <= 0.05
|
||||
}
|
||||
|
||||
// FisheyeLayout reports whether the frame is compatible with a single circular fisheye input.
|
||||
func (m *MediaFile) FisheyeLayout() bool {
|
||||
r := float64(m.AspectRatio())
|
||||
|
||||
return r > 0 && math.Abs(r-1.0) <= 0.2
|
||||
}
|
||||
|
||||
// IsHeif checks if the file is a High Efficiency Image File Format (HEIF) container with a supported file type extension.
|
||||
func (m *MediaFile) IsHeif() bool {
|
||||
return m.IsHeic() || m.IsHeicS() || m.IsAvif() || m.IsAvifS()
|
||||
|
|
@ -1169,7 +1284,26 @@ func (m *MediaFile) NeedsTranscoding() bool {
|
|||
return fs.VideoMp4.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false) == ""
|
||||
}
|
||||
|
||||
return fs.VideoAvc.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false) == ""
|
||||
return m.AvcFile() == nil
|
||||
}
|
||||
|
||||
// AvcFile returns the existing AVC sidecar for a video original, if available.
|
||||
func (m *MediaFile) AvcFile() *MediaFile {
|
||||
if m == nil || m.NotAnimated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
avcName := fs.VideoAvc.FindFirst(m.FileName(), []string{Config().SidecarPath(), fs.PPHiddenPathname}, Config().OriginalsPath(), false)
|
||||
if avcName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := NewMediaFile(avcName)
|
||||
if err != nil || result == nil || result.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SkipTranscoding checks if the media file is not animated or has already been transcoded to a playable format.
|
||||
|
|
|
|||
152
internal/photoprism/mediafile_insta360.go
Normal file
152
internal/photoprism/mediafile_insta360.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
const (
|
||||
insta360PairAspectTolerance = 0.05
|
||||
insta360PairFpsTolerance = 0.1
|
||||
insta360PairDurationTolerance = time.Second
|
||||
)
|
||||
|
||||
// Insta360Capture contains the original lens files and optional low-resolution proxy for one capture.
|
||||
type Insta360Capture struct {
|
||||
Name media.Insta360VideoName
|
||||
Left *MediaFile
|
||||
Right *MediaFile
|
||||
Proxy *MediaFile
|
||||
}
|
||||
|
||||
// FindInsta360Capture resolves the files belonging to the same directory-scoped capture as f.
|
||||
func FindInsta360Capture(f *MediaFile) *Insta360Capture {
|
||||
if f == nil || !f.IsInsv() {
|
||||
return nil
|
||||
}
|
||||
|
||||
name, ok := media.ParseInsta360VideoName(f.FileName())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &Insta360Capture{Name: name}
|
||||
|
||||
for role, fileName := range map[media.Insta360VideoRole]string{
|
||||
media.Insta360VideoLeft: name.FileName(media.Insta360VideoLeft),
|
||||
media.Insta360VideoRight: name.FileName(media.Insta360VideoRight),
|
||||
media.Insta360VideoProxy: name.FileName(media.Insta360VideoProxy),
|
||||
} {
|
||||
if !fs.FileExistsNotEmpty(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
captureFile, err := NewMediaFile(fileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch role {
|
||||
case media.Insta360VideoLeft:
|
||||
result.Left = captureFile
|
||||
case media.Insta360VideoRight:
|
||||
result.Right = captureFile
|
||||
case media.Insta360VideoProxy:
|
||||
result.Proxy = captureFile
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidPair reports whether the two full-resolution lens files can safely be combined.
|
||||
func (m *Insta360Capture) ValidPair() bool {
|
||||
if m == nil || m.Left == nil || m.Right == nil || !m.Left.IsInsv() || !m.Right.IsInsv() {
|
||||
return false
|
||||
}
|
||||
|
||||
leftWidth, leftHeight := m.Left.Width(), m.Left.Height()
|
||||
rightWidth, rightHeight := m.Right.Width(), m.Right.Height()
|
||||
|
||||
if leftWidth > 0 && leftHeight > 0 && math.Abs(float64(leftWidth)/float64(leftHeight)-1) > insta360PairAspectTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if rightWidth > 0 && rightHeight > 0 && math.Abs(float64(rightWidth)/float64(rightHeight)-1) > insta360PairAspectTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftWidth > 0 && rightWidth > 0 && (leftWidth != rightWidth || leftHeight != rightHeight) {
|
||||
return false
|
||||
}
|
||||
|
||||
leftInfo, rightInfo := m.Left.VideoInfo(), m.Right.VideoInfo()
|
||||
|
||||
if leftInfo.FPS > 0 && rightInfo.FPS > 0 && math.Abs(leftInfo.FPS-rightInfo.FPS) > insta360PairFpsTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftInfo.Duration > 0 && rightInfo.Duration > 0 && absDuration(leftInfo.Duration-rightInfo.Duration) > insta360PairDurationTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Files returns capture members in canonical lens and proxy order.
|
||||
func (m *Insta360Capture) Files() MediaFiles {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(MediaFiles, 0, 3)
|
||||
for _, file := range (MediaFiles{m.Left, m.Right, m.Proxy}) {
|
||||
if file != nil {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// DewarpableInsv reports whether an INSV contains or belongs to a complete dual-fisheye frame.
|
||||
func (m *MediaFile) DewarpableInsv() bool {
|
||||
if m == nil || !m.IsInsv() {
|
||||
return false
|
||||
}
|
||||
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.ValidPair() {
|
||||
return true
|
||||
}
|
||||
|
||||
return m.DualFisheyeLayout()
|
||||
}
|
||||
|
||||
// DewarpedVideoFile returns an existing equirectangular AVC for an Insta360 video.
|
||||
func DewarpedVideoFile(m *MediaFile) *MediaFile {
|
||||
if m == nil || !m.DewarpableInsv() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result := m.AvcFile(); result != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.Proxy != nil {
|
||||
return capture.Proxy.AvcFile()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// absDuration returns the absolute value of a duration.
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
145
internal/photoprism/mediafile_insta360_test.go
Normal file
145
internal/photoprism/mediafile_insta360_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// writeInsta360CaptureFile copies a square image fixture to an INSV capture filename for geometry tests.
|
||||
func writeInsta360CaptureFile(t *testing.T, dir, name, fixture string) string {
|
||||
t.Helper()
|
||||
require.NoError(t, fs.MkdirAll(dir))
|
||||
|
||||
// #nosec G304 -- the fixture path is controlled by the test.
|
||||
payload, err := os.ReadFile(fixture)
|
||||
require.NoError(t, err)
|
||||
|
||||
fileName := filepath.Join(dir, name)
|
||||
// #nosec G703 -- the destination directory and filename are controlled by the test.
|
||||
require.NoError(t, os.WriteFile(fileName, payload, fs.ModeFile))
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
// TestFindInsta360Capture verifies exact capture grouping and incomplete-pair fallback.
|
||||
func TestFindInsta360Capture(t *testing.T) {
|
||||
t.Run("CompleteWithProxy", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
capture := FindInsta360Capture(left)
|
||||
require.NotNil(t, capture)
|
||||
assert.True(t, capture.ValidPair())
|
||||
assert.Len(t, capture.Files(), 3)
|
||||
assert.Equal(t, media.Insta360VideoLeft, capture.Name.Role)
|
||||
})
|
||||
t.Run("Incomplete", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
left, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
capture := FindInsta360Capture(left)
|
||||
require.NotNil(t, capture)
|
||||
assert.False(t, capture.ValidPair())
|
||||
assert.Len(t, capture.Files(), 1)
|
||||
})
|
||||
t.Run("Unrelated", func(t *testing.T) {
|
||||
file, err := NewMediaFile("testdata/insta360.insv")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, FindInsta360Capture(file))
|
||||
})
|
||||
}
|
||||
|
||||
func TestForceDewarpPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ordinary, err := NewMediaFile("testdata/flash.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.True(t, forceDewarpPreview(left, true))
|
||||
assert.False(t, forceDewarpPreview(left, false))
|
||||
assert.False(t, forceDewarpPreview(ordinary, true))
|
||||
assert.False(t, forceDewarpPreview(nil, true))
|
||||
}
|
||||
|
||||
// TestDewarpedVideoFile verifies direct AVC selection, LRV fallback, and fail-closed behavior.
|
||||
func TestDewarpedVideoFile(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
proxyName := writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
proxy, err := NewMediaFile(proxyName)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, DewarpedVideoFile(left))
|
||||
|
||||
proxyAvcName, err := fs.FileName(proxy.FileName(), conf.SidecarPath(), conf.OriginalsPath(), fs.ExtAvc)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(proxyAvcName) })
|
||||
videoFixture, err := NewMediaFile(conf.SamplesPath() + "/blue-go-video.mp4")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, videoFixture.Copy(proxyAvcName, false))
|
||||
proxyAvc := DewarpedVideoFile(left)
|
||||
require.NotNil(t, proxyAvc)
|
||||
assert.Equal(t, proxyAvcName, proxyAvc.FileName())
|
||||
|
||||
leftAvcName, err := fs.FileName(left.FileName(), conf.SidecarPath(), conf.OriginalsPath(), fs.ExtAvc)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(leftAvcName) })
|
||||
require.NoError(t, videoFixture.Copy(leftAvcName, false))
|
||||
leftAvc := DewarpedVideoFile(left)
|
||||
require.NotNil(t, leftAvc)
|
||||
assert.Equal(t, leftAvcName, leftAvc.FileName())
|
||||
|
||||
ordinary, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, DewarpedVideoFile(ordinary))
|
||||
assert.Nil(t, DewarpedVideoFile(nil))
|
||||
}
|
||||
|
||||
// TestInsta360Capture_ValidPair verifies geometry and timing safeguards.
|
||||
func TestInsta360Capture_ValidPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
left, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
right, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
left.width, left.height = 3072, 3072
|
||||
right.width, right.height = 3072, 3072
|
||||
capture := &Insta360Capture{Left: left, Right: right}
|
||||
|
||||
assert.True(t, capture.ValidPair())
|
||||
invalidRight, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_009.insv", "testdata/2015-02-04.jpg"))
|
||||
require.NoError(t, err)
|
||||
invalidRight.width, invalidRight.height = 1920, 1080
|
||||
assert.False(t, (&Insta360Capture{Left: left, Right: invalidRight}).ValidPair())
|
||||
assert.False(t, (*Insta360Capture)(nil).ValidPair())
|
||||
}
|
||||
|
||||
// TestAbsDuration verifies duration normalization.
|
||||
func TestAbsDuration(t *testing.T) {
|
||||
assert.Equal(t, absDuration(-5), absDuration(5))
|
||||
}
|
||||
79
internal/photoprism/mediafile_projection.go
Normal file
79
internal/photoprism/mediafile_projection.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// SetVisualProjection records the projection produced by a successful in-process conversion.
|
||||
func (m *MediaFile) SetVisualProjection(value projection.Type) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.visualProjection = value
|
||||
}
|
||||
|
||||
// VisualProjection returns an explicit, metadata, or safely inferred projection for the file.
|
||||
func (m *MediaFile) VisualProjection(metadataValue string) projection.Type {
|
||||
if m == nil {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
if !m.visualProjection.Unknown() {
|
||||
return m.visualProjection
|
||||
}
|
||||
|
||||
if value := projection.New(metadataValue); !value.Unknown() {
|
||||
return value
|
||||
}
|
||||
|
||||
return m.derivedVisualProjection()
|
||||
}
|
||||
|
||||
// derivedVisualProjection recognizes generated 2:1 sidecars whose original source requires dewarping.
|
||||
func (m *MediaFile) derivedVisualProjection() projection.Type {
|
||||
if m == nil || !m.InSidecar() || !m.DualFisheyeLayout() {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
var generatedExt string
|
||||
switch m.FileType() {
|
||||
case fs.ImageJpeg:
|
||||
generatedExt = fs.ExtJpeg
|
||||
case fs.VideoAvc:
|
||||
generatedExt = fs.ExtAvc
|
||||
default:
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
relName := m.RelName(Config().SidecarPath())
|
||||
if !strings.EqualFold(filepath.Ext(relName), generatedExt) {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
sourceRelName := strings.TrimSuffix(relName, filepath.Ext(relName))
|
||||
source, err := NewMediaFile(filepath.Join(Config().OriginalsPath(), sourceRelName))
|
||||
if err != nil || source == nil {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
switch {
|
||||
case source.IsInsp() && source.DualFisheyeLayout():
|
||||
return projection.Equirectangular
|
||||
case source.IsInsv():
|
||||
if capture := FindInsta360Capture(source); capture != nil && capture.ValidPair() {
|
||||
return projection.Equirectangular
|
||||
}
|
||||
if source.DualFisheyeLayout() {
|
||||
return projection.Equirectangular
|
||||
}
|
||||
case source.FisheyeDng():
|
||||
return projection.Equirectangular
|
||||
}
|
||||
|
||||
return projection.Unknown
|
||||
}
|
||||
53
internal/photoprism/mediafile_projection_test.go
Normal file
53
internal/photoprism/mediafile_projection_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// TestMediaFile_VisualProjection verifies explicit, metadata, and generated-sidecar projection paths.
|
||||
func TestMediaFile_VisualProjection(t *testing.T) {
|
||||
t.Run("Explicit", func(t *testing.T) {
|
||||
file := &MediaFile{}
|
||||
file.SetVisualProjection(projection.Equirectangular)
|
||||
assert.Equal(t, projection.Equirectangular, file.VisualProjection(""))
|
||||
})
|
||||
t.Run("Metadata", func(t *testing.T) {
|
||||
file := &MediaFile{}
|
||||
assert.Equal(t, projection.Cubestrip, file.VisualProjection(projection.Cubestrip.String()))
|
||||
})
|
||||
t.Run("GeneratedInspSidecar", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
dir := "projection-persistence"
|
||||
source, err := NewMediaFile("testdata/insta360.insp")
|
||||
require.NoError(t, err)
|
||||
originalName := filepath.Join(conf.OriginalsPath(), dir, "camera.insp")
|
||||
require.NoError(t, source.Copy(originalName, false))
|
||||
|
||||
preview, err := NewMediaFile("testdata/insta360.insp.jpg")
|
||||
require.NoError(t, err)
|
||||
previewName := filepath.Join(conf.SidecarPath(), dir, "camera.insp.jpg")
|
||||
require.NoError(t, preview.Copy(previewName, false))
|
||||
|
||||
generated, err := NewMediaFile(previewName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, projection.Equirectangular, generated.VisualProjection(""))
|
||||
})
|
||||
t.Run("OrdinarySidecar", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
preview, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
previewName := filepath.Join(conf.SidecarPath(), "ordinary.jpg")
|
||||
require.NoError(t, preview.Copy(previewName, false))
|
||||
|
||||
generated, err := NewMediaFile(previewName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, projection.Unknown, generated.VisualProjection(""))
|
||||
})
|
||||
}
|
||||
|
|
@ -59,10 +59,32 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
|
|||
matches = list.Join(matches, files)
|
||||
}
|
||||
|
||||
// Insta360 cameras may store one capture as two full-resolution lens videos plus an optional
|
||||
// low-resolution proxy whose filename has a different prefix. Include the complete capture and
|
||||
// all of its sidecars so the indexer creates one photo instead of three unrelated records.
|
||||
var captureMain string
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.ValidPair() {
|
||||
captureMain = capture.Left.FileName()
|
||||
|
||||
for _, captureFile := range capture.Files() {
|
||||
capturePattern := regexp.QuoteMeta(captureFile.AbsPrefix(false)+".") + "*"
|
||||
if captureMatches, captureErr := filepath.Glob(capturePattern); captureErr == nil {
|
||||
matches = list.Join(matches, captureMatches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHeic := false
|
||||
|
||||
processedMatches := make(map[string]bool, len(matches))
|
||||
|
||||
// Process files that matched the pattern.
|
||||
for _, fileName := range matches {
|
||||
if processedMatches[fileName] {
|
||||
continue
|
||||
}
|
||||
|
||||
processedMatches[fileName] = true
|
||||
f, fileErr := NewMediaFile(fileName)
|
||||
|
||||
if fileErr != nil || f.Empty() || f.IsArchive() {
|
||||
|
|
@ -133,6 +155,17 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
|
|||
|
||||
sort.Sort(result.Files)
|
||||
|
||||
// The left (_00) lens is the canonical main original for a complete Insta360 capture. The
|
||||
// generated equirectangular preview becomes the primary display file later during indexing.
|
||||
if captureMain != "" {
|
||||
for _, file := range result.Files {
|
||||
if file.FileName() == captureMain {
|
||||
result.Main = file
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -141,6 +142,30 @@ func TestMediaFile_RelatedFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
})
|
||||
t.Run("Insta360Capture", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
related, err := left.RelatedFiles(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Len(t, related.Files, 3)
|
||||
assert.Equal(t, filepath.Base(leftName), related.Main.BaseName())
|
||||
assert.ElementsMatch(t, []string{
|
||||
"VID_20220625_140410_00_008.insv",
|
||||
"VID_20220625_140410_10_008.insv",
|
||||
"LRV_20220625_140410_11_008.insv",
|
||||
}, []string{related.Files[0].BaseName(), related.Files[1].BaseName(), related.Files[2].BaseName()})
|
||||
})
|
||||
t.Run("Num2015Num02Num04Jpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile("testdata/2015-02-04.jpg")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package photoprism
|
|||
import (
|
||||
"image"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
var testSamplesPath = fs.Abs("../../assets/samples")
|
||||
|
|
@ -1040,6 +1042,246 @@ func TestMediaFile_HasType(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_IsInsp(t *testing.T) {
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insp")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.IsInsp())
|
||||
assert.False(t, f.IsInsv())
|
||||
assert.True(t, f.IsImage())
|
||||
})
|
||||
t.Run("Jpeg", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.IsInsp())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_IsInsv(t *testing.T) {
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insv")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.IsInsv())
|
||||
assert.False(t, f.IsInsp())
|
||||
assert.True(t, f.IsVideo())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_IsInsta360(t *testing.T) {
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insp")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.IsInsta360())
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insv")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.IsInsta360())
|
||||
})
|
||||
t.Run("Jpeg", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.IsInsta360())
|
||||
})
|
||||
}
|
||||
|
||||
// TestMediaFile_Insta360CameraModel verifies EXIF and video-trailer model resolution.
|
||||
func TestMediaFile_Insta360CameraModel(t *testing.T) {
|
||||
t.Run("OneRSImageMetadata", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInspFixture(t, t.TempDir(), "camera.insp"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Insta360 OneRS", f.Insta360CameraModel())
|
||||
})
|
||||
t.Run("OneRSVideoTrailer", func(t *testing.T) {
|
||||
f, err := NewMediaFile(oneRSInsvFixture(t, t.TempDir(), "camera.insv"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Insta360 OneRS", f.Insta360CameraModel())
|
||||
})
|
||||
t.Run("UnknownVideo", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insv")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, f.Insta360CameraModel())
|
||||
})
|
||||
t.Run("NonInsta360", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, f.Insta360CameraModel())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_DualFisheye(t *testing.T) {
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insp")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.DualFisheye())
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insv")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.DualFisheye())
|
||||
})
|
||||
t.Run("Jpeg", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.DualFisheye())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_DualFisheyeLayout(t *testing.T) {
|
||||
t.Run("KnownTwoToOne", func(t *testing.T) {
|
||||
// insta360.insp is 1024x512; copied to a .jpg it is decoded natively so the aspect is known.
|
||||
dir := t.TempDir()
|
||||
f, err := NewMediaFile(copyFixture(t, dir, "wide.jpg", "testdata/insta360.insp"))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.DualFisheyeLayout())
|
||||
})
|
||||
t.Run("KnownSquare", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg") // 500x500 native → known, not 2:1.
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.DualFisheyeLayout())
|
||||
})
|
||||
t.Run("UnknownProceeds", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/insta360.insp") // no metadata → unknown aspect → proceed.
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.DualFisheyeLayout())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_StackedDualFisheyeLayout(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
f.width = 3264
|
||||
f.height = 6528
|
||||
assert.True(t, f.StackedDualFisheyeLayout())
|
||||
|
||||
f.width = 6528
|
||||
f.height = 3264
|
||||
assert.False(t, f.StackedDualFisheyeLayout())
|
||||
|
||||
f.width = 3264
|
||||
f.height = 3264
|
||||
assert.False(t, f.StackedDualFisheyeLayout())
|
||||
}
|
||||
|
||||
func TestMediaFile_FisheyeDng(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Run("Insta360Dng", func(t *testing.T) {
|
||||
f, err := NewMediaFile(dngFixture(t, dir, "insta360.dng", true))
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, f.FisheyeDng())
|
||||
assert.Equal(t, projection.Fisheye, f.FisheyeDngProjection())
|
||||
|
||||
f.width = 3264
|
||||
f.height = 6528
|
||||
assert.Equal(t, projection.DualFisheye, f.FisheyeDngProjection())
|
||||
})
|
||||
t.Run("OrdinaryDng", func(t *testing.T) {
|
||||
f, err := NewMediaFile(dngFixture(t, dir, "canon.dng", false))
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.FisheyeDng())
|
||||
assert.Equal(t, projection.Unknown, f.FisheyeDngProjection())
|
||||
})
|
||||
t.Run("Model360NotFisheye", func(t *testing.T) {
|
||||
// A non-360 camera whose model merely contains "360" must not be treated as fisheye.
|
||||
cnf := config.TestConfig()
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool is required to build the model fixture")
|
||||
}
|
||||
dst := copyFixture(t, dir, "sx360.dng", filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng"))
|
||||
// #nosec G204 -- arguments are the configured ExifTool binary and a temp file path.
|
||||
if err := exec.Command(cnf.ExifToolBin(), "-q", "-overwrite_original", "-Model=PowerShot SX360", dst).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := NewMediaFile(dst)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.FisheyeDng())
|
||||
})
|
||||
t.Run("NotDng", func(t *testing.T) {
|
||||
f, err := NewMediaFile("testdata/flash.jpg")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, f.FisheyeDng())
|
||||
})
|
||||
}
|
||||
|
||||
// copyFixture copies srcName into dir/name and returns the new path, so tests can assemble fixtures
|
||||
// without committing extra binaries.
|
||||
func copyFixture(t *testing.T, dir, name, srcName string) string {
|
||||
t.Helper()
|
||||
src, err := NewMediaFile(srcName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, name)
|
||||
if err = src.Copy(dst, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// oneRSInsvFixture appends a valid OneRS camera-model field to a copied INSV fixture.
|
||||
func oneRSInsvFixture(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := os.ReadFile("testdata/insta360.insv")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload = append(payload, append([]byte{0x12, 0x0e}, []byte("Insta360 OneRS")...)...)
|
||||
dst := filepath.Join(dir, name)
|
||||
|
||||
// #nosec G703 -- dir and name are controlled by this test helper.
|
||||
if err = os.WriteFile(dst, payload, fs.ModeFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// oneRSInspFixture appends a valid OneRS camera-model field to a copied INSP fixture.
|
||||
func oneRSInspFixture(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
payload, err := os.ReadFile("testdata/insta360.insp")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload = append(payload, []byte("\x00Arashi Vision\x00Insta360 OneRS\x00")...)
|
||||
dst := filepath.Join(dir, name)
|
||||
|
||||
// #nosec G703 -- dir and name are controlled by this test helper.
|
||||
if err = os.WriteFile(dst, payload, fs.ModeFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// dngFixture builds a DNG fixture in dir by copying the repo's sample DNG. When insta360 is true it
|
||||
// rewrites the maker metadata via ExifTool so FisheyeDng() detects it (skipping if ExifTool is
|
||||
// unavailable). This avoids committing duplicate 400 KB DNG binaries just for tests.
|
||||
func dngFixture(t *testing.T, dir, name string, insta360 bool) string {
|
||||
t.Helper()
|
||||
cnf := config.TestConfig()
|
||||
dst := copyFixture(t, dir, name, filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng"))
|
||||
|
||||
if insta360 {
|
||||
if !cnf.ExifToolEnabled() {
|
||||
t.Skip("ExifTool is required to build a fisheye DNG fixture")
|
||||
}
|
||||
// #nosec G204 -- arguments are the configured ExifTool binary and a temp file path.
|
||||
if err := exec.Command(cnf.ExifToolBin(), "-q", "-overwrite_original", "-Make=Insta360", "-Model=Insta360 X4", dst).Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func TestMediaFile_IsHeic(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("IphoneSevenJson", func(t *testing.T) {
|
||||
|
|
@ -2581,6 +2823,34 @@ func TestMediaFile_NeedsTranscoding(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_AvcFile(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
t.Run("Existing", func(t *testing.T) {
|
||||
original, err := NewMediaFile(c.SamplesPath() + "/earth.mov")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
avcName, err := fs.FileName(original.FileName(), c.SidecarPath(), c.OriginalsPath(), fs.ExtAvc)
|
||||
assert.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(avcName) })
|
||||
avcFixture, err := NewMediaFile(c.SamplesPath() + "/blue-go-video.mp4")
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, avcFixture.Copy(avcName, false))
|
||||
|
||||
assert.NotNil(t, original.AvcFile())
|
||||
})
|
||||
t.Run("Missing", func(t *testing.T) {
|
||||
original, err := NewMediaFile(c.SamplesPath() + "/gopher-video.mp4")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, original.AvcFile())
|
||||
})
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
assert.Nil(t, (*MediaFile)(nil).AvcFile())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_SkipTranscoding(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
t.Run("Json", func(t *testing.T) {
|
||||
|
|
|
|||
BIN
internal/photoprism/testdata/insta360.insp
vendored
Normal file
BIN
internal/photoprism/testdata/insta360.insp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
internal/photoprism/testdata/insta360.insp.jpg
vendored
Normal file
BIN
internal/photoprism/testdata/insta360.insp.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
internal/photoprism/testdata/insta360.insv
vendored
Normal file
BIN
internal/photoprism/testdata/insta360.insv
vendored
Normal file
Binary file not shown.
BIN
internal/photoprism/testdata/insta360.insv.jpg
vendored
Normal file
BIN
internal/photoprism/testdata/insta360.insv.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -13,6 +13,7 @@ const (
|
|||
ExtJpeg = ".jpg"
|
||||
ExtPng = ".png"
|
||||
ExtDng = ".dng"
|
||||
ExtInsp = ".insp"
|
||||
ExtThm = ".thm"
|
||||
ExtH264 = ".h264"
|
||||
ExtAvc = ".avc"
|
||||
|
|
@ -45,6 +46,7 @@ const (
|
|||
ExtEvc = ".evc"
|
||||
ExtEvc1 = ".evc1"
|
||||
ExtMp4 = ".mp4"
|
||||
ExtInsv = ".insv"
|
||||
ExtMov = ".mov"
|
||||
ExtQT = ".qt"
|
||||
ExtYml = ".yml"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ var Extensions = FileExtensions{
|
|||
".heifs": ImageHeicS,
|
||||
".heics": ImageHeicS,
|
||||
".webp": ImageWebp,
|
||||
ExtInsp: ImageInsp, // .insp (Insta360 dual-fisheye photo)
|
||||
".mpo": ImageMPO,
|
||||
".3fr": ImageRaw,
|
||||
".ari": ImageRaw,
|
||||
|
|
@ -106,39 +107,40 @@ var Extensions = FileExtensions{
|
|||
".ept": VectorEPS,
|
||||
".epsf": VectorEPS,
|
||||
".epsi": VectorEPS,
|
||||
ExtMov: VideoMov, // Apple QuickTime Video Container
|
||||
ExtQT: VideoMov, // .qt
|
||||
ExtMp4: VideoMp4, // MPEG-4 Part 14 Multimedia Container
|
||||
ExtH264: VideoAvc, // ↓ H.264 MPEG-4 Advanced Video Coding (AVC)
|
||||
ExtAvc: VideoAvc, // .avc
|
||||
ExtAvc1: VideoAvc, // .avc1
|
||||
ExtDva: VideoAvc, // .dva
|
||||
ExtDva1: VideoAvc, // .dva1
|
||||
ExtAvc2: VideoAvc, // .avc2
|
||||
ExtAvc3: VideoAvc, // .avc3
|
||||
ExtDvav: VideoAvc, // .avc3
|
||||
ExtAvc10: VideoAvc, // .avc10
|
||||
ExtH265: VideoHvc, // ↓ H.265 MPEG-4 HEVC with parameter sets only in the Sample Entry
|
||||
ExtHvc: VideoHvc, // .hvc
|
||||
ExtHvc1: VideoHvc, // .hvc1
|
||||
ExtDvh: VideoHvc, // .dvh
|
||||
ExtDvh1: VideoHvc, // .dvh1
|
||||
ExtHvc2: VideoHvc, // .hvc2
|
||||
ExtHvc3: VideoHvc, // .hvc3
|
||||
ExtHvc10: VideoHvc, // .hvc10
|
||||
ExtHevc: VideoHvc, // .hevc
|
||||
ExtHevc10: VideoHvc, // .hevc10
|
||||
ExtHev: VideoHev, // ↓ H.265 video with parameter sets also in the Samples
|
||||
ExtHev1: VideoHev, // .hev1
|
||||
ExtDvhe: VideoHev, // .dvhe
|
||||
ExtHev2: VideoHev, // .hev2
|
||||
ExtHev3: VideoHev, // .hev3
|
||||
ExtHev10: VideoHev, // .hev10
|
||||
ExtH266: VideoVvc, // ↓ H.266 MPEG-4 Versatile Video Coding (VVC)
|
||||
ExtVvc: VideoVvc, // .vvc
|
||||
ExtVvc1: VideoVvc, // .vvc1
|
||||
ExtEvc: VideoEvc, // ↓ MPEG-5 Essential Video Coding (EVC)
|
||||
ExtEvc1: VideoEvc, // .evc1
|
||||
ExtMov: VideoMov, // Apple QuickTime Video Container
|
||||
ExtQT: VideoMov, // .qt
|
||||
ExtMp4: VideoMp4, // MPEG-4 Part 14 Multimedia Container
|
||||
ExtInsv: VideoInsv, // .insv (Insta360 dual-fisheye video)
|
||||
ExtH264: VideoAvc, // ↓ H.264 MPEG-4 Advanced Video Coding (AVC)
|
||||
ExtAvc: VideoAvc, // .avc
|
||||
ExtAvc1: VideoAvc, // .avc1
|
||||
ExtDva: VideoAvc, // .dva
|
||||
ExtDva1: VideoAvc, // .dva1
|
||||
ExtAvc2: VideoAvc, // .avc2
|
||||
ExtAvc3: VideoAvc, // .avc3
|
||||
ExtDvav: VideoAvc, // .avc3
|
||||
ExtAvc10: VideoAvc, // .avc10
|
||||
ExtH265: VideoHvc, // ↓ H.265 MPEG-4 HEVC with parameter sets only in the Sample Entry
|
||||
ExtHvc: VideoHvc, // .hvc
|
||||
ExtHvc1: VideoHvc, // .hvc1
|
||||
ExtDvh: VideoHvc, // .dvh
|
||||
ExtDvh1: VideoHvc, // .dvh1
|
||||
ExtHvc2: VideoHvc, // .hvc2
|
||||
ExtHvc3: VideoHvc, // .hvc3
|
||||
ExtHvc10: VideoHvc, // .hvc10
|
||||
ExtHevc: VideoHvc, // .hevc
|
||||
ExtHevc10: VideoHvc, // .hevc10
|
||||
ExtHev: VideoHev, // ↓ H.265 video with parameter sets also in the Samples
|
||||
ExtHev1: VideoHev, // .hev1
|
||||
ExtDvhe: VideoHev, // .dvhe
|
||||
ExtHev2: VideoHev, // .hev2
|
||||
ExtHev3: VideoHev, // .hev3
|
||||
ExtHev10: VideoHev, // .hev10
|
||||
ExtH266: VideoVvc, // ↓ H.266 MPEG-4 Versatile Video Coding (VVC)
|
||||
ExtVvc: VideoVvc, // .vvc
|
||||
ExtVvc1: VideoVvc, // .vvc1
|
||||
ExtEvc: VideoEvc, // ↓ MPEG-5 Essential Video Coding (EVC)
|
||||
ExtEvc1: VideoEvc, // .evc1
|
||||
".vp8": VideoVp8,
|
||||
".vp9": VideoVp9,
|
||||
".av1": VideoAv1,
|
||||
|
|
|
|||
|
|
@ -189,7 +189,12 @@ func TestFileType(t *testing.T) {
|
|||
t.Run("Mp4", func(t *testing.T) {
|
||||
assert.Equal(t, Type("mp4"), FileType("file.mp4"))
|
||||
})
|
||||
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
assert.Equal(t, ImageInsp, FileType("IMG_20180318_205851_239.insp"))
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
assert.Equal(t, VideoInsv, FileType("VID_20220607_102410_00_322.insv"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsAnimatedImage(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const (
|
|||
ImageHeic Type = "heic" // High Efficiency Image Container (HEIC)
|
||||
ImageHeicS Type = "heics" // HEIC Image Sequence
|
||||
ImageWebp Type = "webp" // Google WebP Image
|
||||
ImageInsp Type = "insp" // Insta360 Panoramic Image (JPEG carrying dual-fisheye data)
|
||||
)
|
||||
|
||||
// Supported media.Raw file types:
|
||||
|
|
@ -93,6 +94,7 @@ const (
|
|||
VideoAVI Type = "avi" // Microsoft Audio Video Interleave (AVI)
|
||||
VideoWMV Type = "wmv" // Windows Media Video (based on ASF)
|
||||
VideoDV Type = "dv" // DV Video (https://en.wikipedia.org/wiki/DV)
|
||||
VideoInsv Type = "insv" // Insta360 Video (MP4 container carrying dual-fisheye data)
|
||||
)
|
||||
|
||||
// TypeUnknown is the default type used when a file cannot be classified.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ func TestFromName(t *testing.T) {
|
|||
result := FromName("testdata/gopher.mp4")
|
||||
assert.Equal(t, Video, result)
|
||||
})
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
assert.Equal(t, Image, FromName("IMG_20180318_205851_239.insp"))
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
assert.Equal(t, Video, FromName("VID_20220607_102410_00_322.insv"))
|
||||
})
|
||||
t.Run("Sidecar", func(t *testing.T) {
|
||||
result := FromName("/IMG_4120.AAE")
|
||||
assert.Equal(t, Sidecar, result)
|
||||
|
|
@ -40,4 +46,10 @@ func TestMainFile(t *testing.T) {
|
|||
t.Run("False", func(t *testing.T) {
|
||||
assert.False(t, MainFile("/IMG_4120.XXX"))
|
||||
})
|
||||
t.Run("Insp", func(t *testing.T) {
|
||||
assert.True(t, MainFile("IMG_20180318_205851_239.insp"))
|
||||
})
|
||||
t.Run("Insv", func(t *testing.T) {
|
||||
assert.True(t, MainFile("VID_20220607_102410_00_322.insv"))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ var Formats = map[fs.Type]Type{
|
|||
fs.ImageHeic: Image,
|
||||
fs.ImageHeicS: Image,
|
||||
fs.ImageWebp: Image,
|
||||
fs.ImageInsp: Image,
|
||||
fs.ImageRaw: Raw,
|
||||
fs.ImageDng: Raw,
|
||||
fs.SidecarXMP: Sidecar,
|
||||
|
|
@ -62,5 +63,6 @@ var Formats = map[fs.Type]Type{
|
|||
fs.VideoASF: Video,
|
||||
fs.VideoWMV: Video,
|
||||
fs.VideoDV: Video,
|
||||
fs.VideoInsv: Video,
|
||||
fs.TypeUnknown: Sidecar,
|
||||
}
|
||||
|
|
|
|||
162
pkg/media/insta360.go
Normal file
162
pkg/media/insta360.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Supported Insta360 video roles identify the original lens and proxy files in a capture.
|
||||
const (
|
||||
insta360MetadataScanLimit = 64 * 1024
|
||||
insta360OneRSModel = "Insta360 OneRS"
|
||||
insta360OneRSImageField = "\x00Arashi Vision\x00Insta360 OneRS\x00"
|
||||
)
|
||||
|
||||
var insta360OneRSVideoField = append([]byte{0x12, byte(len(insta360OneRSModel))}, []byte(insta360OneRSModel)...)
|
||||
|
||||
var insta360VideoName = regexp.MustCompile(`(?i)^(VID|LRV)_(\d{8})_(\d{6})_(00|10|11)_(\d{3})\.insv$`)
|
||||
|
||||
// Insta360VideoRole identifies a file's purpose within a multi-file Insta360 capture.
|
||||
type Insta360VideoRole string
|
||||
|
||||
const (
|
||||
// Insta360VideoUnknown identifies an unsupported or unrecognized capture filename.
|
||||
Insta360VideoUnknown Insta360VideoRole = ""
|
||||
// Insta360VideoLeft identifies the canonical _00 lens original.
|
||||
Insta360VideoLeft Insta360VideoRole = "left"
|
||||
// Insta360VideoRight identifies the matching _10 lens original.
|
||||
Insta360VideoRight Insta360VideoRole = "right"
|
||||
// Insta360VideoProxy identifies the low-resolution _11 proxy.
|
||||
Insta360VideoProxy Insta360VideoRole = "proxy"
|
||||
)
|
||||
|
||||
// Insta360VideoName contains the normalized identity of an Insta360 video capture file.
|
||||
type Insta360VideoName struct {
|
||||
Directory string
|
||||
Date string
|
||||
Time string
|
||||
Sequence string
|
||||
Role Insta360VideoRole
|
||||
}
|
||||
|
||||
// ParseInsta360VideoName parses a supported Insta360 multi-file video filename.
|
||||
func ParseInsta360VideoName(fileName string) (result Insta360VideoName, ok bool) {
|
||||
baseName := filepath.Base(fileName)
|
||||
matches := insta360VideoName.FindStringSubmatch(baseName)
|
||||
|
||||
if len(matches) != 6 {
|
||||
return result, false
|
||||
}
|
||||
|
||||
prefix := strings.ToUpper(matches[1])
|
||||
lens := matches[4]
|
||||
|
||||
switch {
|
||||
case prefix == "VID" && lens == "00":
|
||||
result.Role = Insta360VideoLeft
|
||||
case prefix == "VID" && lens == "10":
|
||||
result.Role = Insta360VideoRight
|
||||
case prefix == "LRV" && lens == "11":
|
||||
result.Role = Insta360VideoProxy
|
||||
default:
|
||||
return Insta360VideoName{}, false
|
||||
}
|
||||
|
||||
result.Directory = filepath.Dir(fileName)
|
||||
result.Date = matches[2]
|
||||
result.Time = matches[3]
|
||||
result.Sequence = matches[5]
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
// CaptureKey returns a directory-scoped identity shared by all files in the capture.
|
||||
func (m Insta360VideoName) CaptureKey() string {
|
||||
if m.Date == "" || m.Time == "" || m.Sequence == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return filepath.Join(m.Directory, m.Date+"_"+m.Time+"_"+m.Sequence)
|
||||
}
|
||||
|
||||
// FileName returns the expected filename for the specified capture role.
|
||||
func (m Insta360VideoName) FileName(role Insta360VideoRole) string {
|
||||
var prefix, lens string
|
||||
|
||||
switch role {
|
||||
case Insta360VideoLeft:
|
||||
prefix, lens = "VID", "00"
|
||||
case Insta360VideoRight:
|
||||
prefix, lens = "VID", "10"
|
||||
case Insta360VideoProxy:
|
||||
prefix, lens = "LRV", "11"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
if m.Date == "" || m.Time == "" || m.Sequence == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
baseName := fmt.Sprintf("%s_%s_%s_%s_%s.insv", prefix, m.Date, m.Time, lens, m.Sequence)
|
||||
|
||||
return filepath.Join(m.Directory, baseName)
|
||||
}
|
||||
|
||||
// Insta360CameraModelFile returns a recognized camera model from an Insta360 original.
|
||||
func Insta360CameraModelFile(fileName string) (model string, err error) {
|
||||
if fileName == "" {
|
||||
return "", errors.New("filename missing")
|
||||
}
|
||||
|
||||
// #nosec G304 -- the media filename is validated and supplied by the caller.
|
||||
file, err := os.Open(fileName)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = errors.Join(err, file.Close())
|
||||
}()
|
||||
|
||||
fileSize, err := file.Seek(0, io.SeekEnd)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if fileSize <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
headSize := min(fileSize, int64(insta360MetadataScanLimit))
|
||||
head := make([]byte, headSize)
|
||||
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
} else if _, err = io.ReadFull(file, head); err != nil {
|
||||
return "", fmt.Errorf("read Insta360 metadata header: %w", err)
|
||||
} else if bytes.Contains(head, []byte(insta360OneRSImageField)) {
|
||||
return insta360OneRSModel, nil
|
||||
}
|
||||
|
||||
tailSize := min(fileSize, int64(insta360MetadataScanLimit))
|
||||
tail := make([]byte, tailSize)
|
||||
|
||||
if _, err = file.Seek(-tailSize, io.SeekEnd); err != nil {
|
||||
return "", err
|
||||
} else if _, err = io.ReadFull(file, tail); err != nil {
|
||||
return "", fmt.Errorf("read Insta360 metadata trailer: %w", err)
|
||||
}
|
||||
|
||||
if bytes.Contains(tail, insta360OneRSVideoField) {
|
||||
return insta360OneRSModel, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
115
pkg/media/insta360_test.go
Normal file
115
pkg/media/insta360_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// writeInsta360Fixture writes a temporary media file with the specified payload.
|
||||
func writeInsta360Fixture(t *testing.T, payload []byte) string {
|
||||
t.Helper()
|
||||
|
||||
fileName := filepath.Join(t.TempDir(), "camera.insp")
|
||||
require.NoError(t, os.WriteFile(fileName, payload, fs.ModeFile))
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
// TestInsta360CameraModelFile verifies bounded OneRS metadata detection and safe fallbacks.
|
||||
func TestInsta360CameraModelFile(t *testing.T) {
|
||||
t.Run("OneRSImage", func(t *testing.T) {
|
||||
payload := append([]byte(insta360OneRSImageField), bytes.Repeat([]byte{0x7f}, 256)...)
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, payload))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, insta360OneRSModel, model)
|
||||
})
|
||||
t.Run("OneRSVideo", func(t *testing.T) {
|
||||
payload := append(bytes.Repeat([]byte{0x7f}, 256), insta360OneRSVideoField...)
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, payload))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, insta360OneRSModel, model)
|
||||
})
|
||||
t.Run("PlainTextRejected", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, []byte(insta360OneRSModel)))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("TruncatedFieldRejected", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, append([]byte{0x12, 0x0e}, []byte("Insta360 One")...)))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("OutsideScanLimitsRejected", func(t *testing.T) {
|
||||
padding := bytes.Repeat([]byte{0}, insta360MetadataScanLimit+1)
|
||||
payload := append(append(append([]byte{}, padding...), insta360OneRSVideoField...), padding...)
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, payload))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, []byte{0x12, 0x0b, 'O', 't', 'h', 'e', 'r'}))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile(writeInsta360Fixture(t, nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("MissingFilename", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile("")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
t.Run("MissingFile", func(t *testing.T) {
|
||||
model, err := Insta360CameraModelFile(filepath.Join(t.TempDir(), "missing.insv"))
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, model)
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseInsta360VideoName verifies strict capture identity and role parsing.
|
||||
func TestParseInsta360VideoName(t *testing.T) {
|
||||
t.Run("Left", func(t *testing.T) {
|
||||
name, ok := ParseInsta360VideoName("videos/VID_20220625_140410_00_008.insv")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, Insta360VideoLeft, name.Role)
|
||||
assert.Equal(t, filepath.Join("videos", "20220625_140410_008"), name.CaptureKey())
|
||||
assert.Equal(t, filepath.Join("videos", "VID_20220625_140410_00_008.insv"), name.FileName(Insta360VideoLeft))
|
||||
assert.Equal(t, filepath.Join("videos", "VID_20220625_140410_10_008.insv"), name.FileName(Insta360VideoRight))
|
||||
assert.Equal(t, filepath.Join("videos", "LRV_20220625_140410_11_008.insv"), name.FileName(Insta360VideoProxy))
|
||||
})
|
||||
t.Run("RightUppercaseExtension", func(t *testing.T) {
|
||||
name, ok := ParseInsta360VideoName("VID_20220625_140410_10_008.INSV")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, Insta360VideoRight, name.Role)
|
||||
})
|
||||
t.Run("Proxy", func(t *testing.T) {
|
||||
name, ok := ParseInsta360VideoName("LRV_20220625_140410_11_008.insv")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, Insta360VideoProxy, name.Role)
|
||||
})
|
||||
|
||||
invalid := []string{
|
||||
"VID_20220625_140410_11_008.insv",
|
||||
"LRV_20220625_140410_00_008.insv",
|
||||
"VID_20220625_140410_01_008.insv",
|
||||
"VID_20220625_140410_00_08.insv",
|
||||
"VID_20220625_140410_00_008.mp4",
|
||||
"copy-VID_20220625_140410_00_008.insv",
|
||||
}
|
||||
|
||||
for _, fileName := range invalid {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
_, ok := ParseInsta360VideoName(fileName)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,10 @@ func TestFromName(t *testing.T) {
|
|||
result := Find("Equirectangular ")
|
||||
assert.Equal(t, Equirectangular, result)
|
||||
})
|
||||
t.Run(Fisheye.String(), func(t *testing.T) {
|
||||
assert.Equal(t, Fisheye, Find("Fisheye"))
|
||||
})
|
||||
t.Run(DualFisheye.String(), func(t *testing.T) {
|
||||
assert.Equal(t, DualFisheye, Find("dual-fisheye"))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ func (t Type) Unknown() bool {
|
|||
return t == Unknown
|
||||
}
|
||||
|
||||
// Fisheye checks if the type is a fisheye or dual-fisheye projection, which
|
||||
// require a dewarp to equirectangular before they can be shown in a sphere viewer.
|
||||
func (t Type) Fisheye() bool {
|
||||
return t == Fisheye || t == DualFisheye
|
||||
}
|
||||
|
||||
// Equal checks if the type matches.
|
||||
func (t Type) Equal(s string) bool {
|
||||
return strings.EqualFold(s, t.String())
|
||||
|
|
|
|||
|
|
@ -40,3 +40,10 @@ func TestType_UnknownMethod(t *testing.T) {
|
|||
assert.True(t, Unknown.Unknown())
|
||||
assert.False(t, Equirectangular.Unknown())
|
||||
}
|
||||
|
||||
func TestType_Fisheye(t *testing.T) {
|
||||
assert.True(t, Fisheye.Fisheye())
|
||||
assert.True(t, DualFisheye.Fisheye())
|
||||
assert.False(t, Equirectangular.Fisheye())
|
||||
assert.False(t, Unknown.Fisheye())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const (
|
|||
TransverseCylindrical Type = "transverse-cylindrical"
|
||||
// PseudocylindricalCompromise projection type.
|
||||
PseudocylindricalCompromise Type = "pseudocylindrical-compromise"
|
||||
// Fisheye projection type, e.g. a single circular fisheye lens capture.
|
||||
Fisheye Type = "fisheye"
|
||||
// DualFisheye projection type, e.g. two side-by-side fisheye circles from a 360° camera.
|
||||
DualFisheye Type = "dual-fisheye"
|
||||
// Other projection type.
|
||||
Other Type = "other"
|
||||
)
|
||||
|
|
@ -25,6 +29,8 @@ var Types = Known{
|
|||
string(Cylindrical): Cylindrical,
|
||||
string(TransverseCylindrical): TransverseCylindrical,
|
||||
string(PseudocylindricalCompromise): PseudocylindricalCompromise,
|
||||
string(Fisheye): Fisheye,
|
||||
string(DualFisheye): DualFisheye,
|
||||
}
|
||||
|
||||
// Known maps names to standard projection types.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue