Media: Render supported Insta360 originals as 360° media #5711

This commit is contained in:
Ömer Duran 2026-07-16 15:33:49 +02:00
parent e09ad8743b
commit 26f5a6c9a1
No known key found for this signature in database
GPG key ID: 2550B0D579890013
26 changed files with 1398 additions and 50 deletions

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

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

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"
)
@ -303,6 +304,21 @@ func TestSphereProjection(t *testing.T) {
}
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

@ -20,9 +20,31 @@ func V360DualFisheyeToEquirect(fov, roll int) string {
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,
@ -30,8 +52,86 @@ func DewarpDualFisheyeToJpegCmd(inputName, jpegName string, fov, roll int, opt *
"-loglevel", "error",
"-y",
"-i", inputName, // input dual-fisheye image
"-vf", V360DualFisheyeToEquirect(fov, roll), // dewarp to equirectangular
"-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

@ -16,6 +16,11 @@ func TestV360DualFisheyeToEquirect(t *testing.T) {
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"}
@ -40,3 +45,42 @@ func TestDewarpDualFisheyeToJpegCmd_MissingBinary(t *testing.T) {
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

@ -241,12 +241,25 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
}
}
// Fisheye 360° DNGs were developed to a dual-fisheye JPEG above; dewarp that JPEG to
// equirectangular now, since FFmpeg cannot develop RAW itself. Gated on the developed frame being
// a ~2:1 dual-fisheye layout so a misdetected or single-fisheye DNG is left untouched. Best
// effort: a failure leaves the developed (non-dewarped) JPEG usable.
if f.FisheyeDng() && result.IsJpeg() && result.DualFisheyeLayout() {
if dewarpErr := w.dewarpFileInPlace(result.FileName(), w.fisheyeFov(f), 0); dewarpErr != nil {
// 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
@ -258,6 +271,8 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
// 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()))
}
@ -303,10 +318,10 @@ func (w *Convert) writeEquirectangularProjection(fileName string) error {
return nil
}
// dewarpFileInPlace dewarps a dual-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. fov and roll define the spherical conversion profile.
func (w *Convert) dewarpFileInPlace(fileName string, fov, roll int) error {
// 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")
}
@ -317,7 +332,16 @@ func (w *Convert) dewarpFileInPlace(fileName string, fov, roll int) error {
// and removed on any error path so no stray "<name>.dewarp.jpg" is left to be indexed.
defer func() { _ = os.Remove(tmpName) }()
cmd := ffmpeg.DewarpDualFisheyeToJpegCmd(fileName, tmpName, fov, roll, &encode.Options{Bin: w.conf.FFmpegBin()})
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

View file

@ -25,6 +25,16 @@ 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
@ -32,7 +42,7 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string)
// 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()})).
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),
)

View file

@ -380,6 +380,28 @@ func TestConvert_JpegConvertCmds_Insp(t *testing.T) {
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) {
@ -421,16 +443,40 @@ func TestConvert_dewarpFileInPlace(t *testing.T) {
}
dir := t.TempDir()
dst := copyFixture(t, dir, "df.jpg", "testdata/insta360.insp") // 2:1 dual-fisheye JPEG.
require.NoError(t, convert.dewarpFileInPlace(dst, 204, 0))
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"), 204, 0)
err := NewConvert(cnf).dewarpFileInPlace(filepath.Join(t.TempDir(), "x.jpg"), projection.DualFisheye, false, 204, 0)
assert.Error(t, err)
})
}
@ -470,7 +516,7 @@ func TestConvert_fisheyeFov(t *testing.T) {
})
}
// TestConvert_fisheyeRoll verifies that only 2:1 OneRS INSP and INSV originals are corrected.
// TestConvert_fisheyeRoll verifies that only supported OneRS fisheye originals are corrected.
func TestConvert_fisheyeRoll(t *testing.T) {
cnf := config.TestConfig()
convert := NewConvert(cnf)
@ -503,6 +549,17 @@ func TestConvert_fisheyeRoll(t *testing.T) {
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

View file

@ -18,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.
@ -27,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())
@ -76,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
@ -185,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.
@ -207,11 +220,11 @@ func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode
return exec.Command(w.conf.ImageMagickBin(), f.FileName(), avcName), false, nil
}
// Dewarp Insta360 dual-fisheye video only when the frame is a side-by-side ~2:1 layout — X3/X4
// per-lens streams and single-lens .insv are not, and applying the dual-fisheye filter to them
// would corrupt the transcode. The v360 filter runs on the CPU and needs frames in system
// memory, so the hardware encoders are bypassed for these.
dewarp := f.IsInsv() && f.DualFisheyeLayout()
// 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
@ -227,6 +240,10 @@ func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode
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)
}
@ -250,11 +267,21 @@ func (w *Convert) fisheyeFov(f *MediaFile) int {
// 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.DualFisheyeLayout() {
if f == nil || !f.DualFisheye() && !f.FisheyeDng() {
return 0
}
roll := entity.CameraFisheyeRoll(f.CameraMake(), f.Insta360CameraModel())
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()))

View file

@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
@ -42,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) {
@ -229,6 +242,27 @@ func TestConvert_TranscodeToAvcCmd(t *testing.T) {
}
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 {

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

@ -433,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)
@ -588,7 +588,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)
@ -597,7 +597,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot
// 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(projection.DualFisheye.String())
file.SetProjection(m.FisheyeDngProjection().String())
}
// Get video metadata from embedded file?
@ -690,7 +690,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)
@ -786,7 +786,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)

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

@ -66,6 +66,7 @@ type MediaFile struct {
videoOnce sync.Once
insta360Model string
insta360Once sync.Once
visualProjection projection.Type
fileMutex sync.Mutex
location *entity.Cell
imageConfig *image.Config
@ -965,23 +966,33 @@ func (m *MediaFile) DualFisheye() bool {
return m.IsInsp() || m.IsInsv()
}
// FisheyeDng checks if the file is a fisheye 360° DNG — a dual-fisheye RAW capture from a 360 rig
// such as Insta360 ONE/X or Ricoh Theta, which must be developed and then dewarped. Unlike
// .insp/.insv the .dng extension does not imply fisheye, so detection is a conservative metadata
// heuristic that leaves ordinary rectilinear DNGs untouched.
// 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 false
return projection.Unknown
}
if m.IsInsta360() || projection.New(m.MetaData().Projection).Fisheye() {
return true
if detected := projection.New(m.MetaData().Projection); detected.Fisheye() {
return detected
}
// Ricoh Theta is the other common 360 rig that stores dual-fisheye DNGs. A bare "360" or a
// single-lens "fisheye" lens name is deliberately not matched: those are not side-by-side
// dual-fisheye and would be corrupted by the dual-fisheye dewarp.
return strings.Contains(strings.ToLower(m.CameraModel()), "theta")
// 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
@ -995,6 +1006,20 @@ func (m *MediaFile) DualFisheyeLayout() bool {
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()
@ -1259,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

@ -16,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")
@ -1146,17 +1147,40 @@ func TestMediaFile_DualFisheyeLayout(t *testing.T) {
})
}
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.
@ -2799,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) {

View file

@ -6,8 +6,12 @@ import (
"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"
@ -16,6 +20,95 @@ const (
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 == "" {
@ -59,7 +152,9 @@ func Insta360CameraModelFile(fileName string) (model string, err error) {
return "", err
} else if _, err = io.ReadFull(file, tail); err != nil {
return "", fmt.Errorf("read Insta360 metadata trailer: %w", err)
} else if bytes.Contains(tail, insta360OneRSVideoField) {
}
if bytes.Contains(tail, insta360OneRSVideoField) {
return insta360OneRSModel, nil
}

View file

@ -74,3 +74,42 @@ func TestInsta360CameraModelFile(t *testing.T) {
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)
})
}
}