This commit is contained in:
Ömer Duran 2026-07-16 21:57:09 +00:00 committed by GitHub
commit c3335503a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 2751 additions and 61 deletions

View file

@ -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`) }}

View file

@ -676,6 +676,9 @@
"FFmpegEncoder": {
"type": "string"
},
"FFmpegFisheyeFov": {
"type": "integer"
},
"FFmpegMapAudio": {
"type": "string"
},

View file

@ -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",

View file

@ -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 {

View file

@ -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())

View file

@ -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",

View file

@ -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"`

View file

@ -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()},

View 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
}
}

View 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("", ""))
})
}

View file

@ -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")

View 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))
}

View file

@ -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")

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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
)

View file

@ -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.

View file

@ -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
View 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,
)
}

View 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")
}

View file

@ -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)"`

View file

@ -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)"`

View file

@ -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 {

View file

@ -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)
}

View file

@ -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) {

View file

@ -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

View file

@ -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.

View file

@ -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")
})
}

View file

@ -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:

View 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
}

View 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)
}
}

View file

@ -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())
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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.

View file

@ -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.

View 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
}

View 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))
}

View 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
}

View 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(""))
})
}

View file

@ -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
}

View file

@ -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")

View file

@ -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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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"

View file

@ -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,

View file

@ -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) {

View file

@ -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.

View file

@ -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"))
})
}

View file

@ -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
View 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
View 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)
})
}
}

View file

@ -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"))
})
}

View file

@ -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())

View file

@ -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())
}

View file

@ -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.