Media: Correct Insta360 OneRS sphere orientation #5711

This commit is contained in:
Ömer Duran 2026-07-16 02:36:01 +02:00
parent a67ae89798
commit e09ad8743b
No known key found for this signature in database
GPG key ID: 2550B0D579890013
13 changed files with 402 additions and 20 deletions

View file

@ -12,7 +12,7 @@ func CameraFisheyeFov(makeName, modelName string) int {
model := strings.ToLower(strings.TrimSpace(modelName))
switch {
case strings.Contains(model, "insta360 x") || strings.Contains(model, "one x") || strings.Contains(model, "one rs"):
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).
@ -22,3 +22,20 @@ func CameraFisheyeFov(makeName, modelName string) int {
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

@ -13,6 +13,9 @@ func TestCameraFisheyeFov(t *testing.T) {
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"))
})
@ -30,3 +33,25 @@ func TestCameraFisheyeFov(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

@ -9,14 +9,20 @@ import (
// 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 (~204 for typical Insta360 X-series cameras).
func V360DualFisheyeToEquirect(fov int) string {
return fmt.Sprintf("v360=input=dfisheye:output=e:ih_fov=%d:iv_fov=%d", fov, fov)
// 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
}
// 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 int, opt *encode.Options) *exec.Cmd {
func DewarpDualFisheyeToJpegCmd(inputName, jpegName string, fov, roll int, opt *encode.Options) *exec.Cmd {
// #nosec G204 -- paths and flags are created by the application, not user input.
return exec.Command(
opt.Bin,
@ -24,7 +30,7 @@ func DewarpDualFisheyeToJpegCmd(inputName, jpegName string, fov int, opt *encode
"-loglevel", "error",
"-y",
"-i", inputName, // input dual-fisheye image
"-vf", V360DualFisheyeToEquirect(fov), // dewarp to equirectangular
"-vf", V360DualFisheyeToEquirect(fov, roll), // dewarp to equirectangular
"-frames:v", "1", // write a single frame
jpegName, // output equirectangular JPEG
)

View file

@ -12,8 +12,8 @@ import (
)
func TestV360DualFisheyeToEquirect(t *testing.T) {
assert.Equal(t, "v360=input=dfisheye:output=e:ih_fov=204:iv_fov=204", V360DualFisheyeToEquirect(204))
assert.Equal(t, "v360=input=dfisheye:output=e:ih_fov=190:iv_fov=190", V360DualFisheyeToEquirect(190))
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 TestDewarpDualFisheyeToJpegCmd(t *testing.T) {
@ -22,13 +22,13 @@ func TestDewarpDualFisheyeToJpegCmd(t *testing.T) {
srcName := fs.Abs("./testdata/dualfisheye.insp")
destName := fs.Abs("./testdata/dualfisheye.jpg")
cmd := DewarpDualFisheyeToJpegCmd(srcName, destName, 190, opt)
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)+" -frames:v 1 DEST", cmdStr)
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.
@ -36,7 +36,7 @@ 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, opt)
cmd := DewarpDualFisheyeToJpegCmd(srcName, destName, 204, 0, opt)
err := cmd.Run()
assert.Error(t, err)
}

View file

@ -246,7 +246,7 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
// 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)); dewarpErr != nil {
if dewarpErr := w.dewarpFileInPlace(result.FileName(), w.fisheyeFov(f), 0); dewarpErr != nil {
log.Warnf("convert: %s in %s (dewarp)", clean.Error(dewarpErr), clean.Log(result.RootRelName()))
} else {
fileProjection = projection.Equirectangular
@ -305,8 +305,8 @@ func (w *Convert) writeEquirectangularProjection(fileName string) error {
// 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 is the per-lens field of view in degrees.
func (w *Convert) dewarpFileInPlace(fileName string, fov int) error {
// 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 {
if !w.conf.FFmpegEnabled() {
return errors.New("ffmpeg is disabled")
}
@ -317,7 +317,7 @@ func (w *Convert) dewarpFileInPlace(fileName string, fov 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, &encode.Options{Bin: w.conf.FFmpegBin()})
cmd := ffmpeg.DewarpDualFisheyeToJpegCmd(fileName, tmpName, fov, roll, &encode.Options{Bin: w.conf.FFmpegBin()})
cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", w.conf.CmdCachePath()))
var stderr bytes.Buffer

View file

@ -32,7 +32,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), &encode.Options{Bin: w.conf.FFmpegBin()})).
ffmpeg.DewarpDualFisheyeToJpegCmd(f.FileName(), jpegName, w.fisheyeFov(f), w.fisheyeRoll(f), &encode.Options{Bin: w.conf.FFmpegBin()})).
WithImageVerification().
WithProjection(projection.Equirectangular),
)

View file

@ -368,8 +368,16 @@ func TestConvert_JpegConvertCmds_Insp(t *testing.T) {
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_writeEquirectangularProjection verifies that the GPano equirectangular tag is
@ -413,7 +421,7 @@ 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))
require.NoError(t, convert.dewarpFileInPlace(dst, 204, 0))
assert.False(t, fs.FileExists(dst+".dewarp.jpg"), "temp file must not leak")
out, err := NewMediaFile(dst)
require.NoError(t, err)
@ -422,7 +430,7 @@ func TestConvert_dewarpFileInPlace(t *testing.T) {
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)
err := NewConvert(cnf).dewarpFileInPlace(filepath.Join(t.TempDir(), "x.jpg"), 204, 0)
assert.Error(t, err)
})
}
@ -450,6 +458,51 @@ func TestConvert_fisheyeFov(t *testing.T) {
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 2:1 OneRS INSP and INSV 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))
})
}
// TestConvert_JpegConvertCmds_RawEmbeddedPreview verifies that RAW inputs emit

View file

@ -224,7 +224,7 @@ func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode
}
if dewarp {
opt.V360 = ffmpeg.V360DualFisheyeToEquirect(w.fisheyeFov(f))
opt.V360 = ffmpeg.V360DualFisheyeToEquirect(w.fisheyeFov(f), w.fisheyeRoll(f))
}
return ffmpeg.TranscodeCmd(fileName, avcName, opt)
@ -234,7 +234,13 @@ func (w *Convert) TranscodeToAvcCmd(f *MediaFile, avcName string, encoder encode
// preferring a per-camera default and falling back to the configured FFmpegFisheyeFov.
func (w *Convert) fisheyeFov(f *MediaFile) int {
if f != nil {
if fov := entity.CameraFisheyeFov(f.CameraMake(), f.CameraModel()); fov > 0 {
model := f.CameraModel()
if model == "" && f.DualFisheye() {
model = f.Insta360CameraModel()
}
if fov := entity.CameraFisheyeFov(f.CameraMake(), model); fov > 0 {
return fov
}
}
@ -242,6 +248,21 @@ func (w *Convert) fisheyeFov(f *MediaFile) int {
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.DualFisheyeLayout() {
return 0
}
roll := entity.CameraFisheyeRoll(f.CameraMake(), f.Insta360CameraModel())
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.
func (w *Convert) AvcBitrate(f *MediaFile) string {
const defaultBitrate = "8M"

View file

@ -202,8 +202,33 @@ func TestConvert_TranscodeToAvcCmd(t *testing.T) {
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("Mp4NoV360", func(t *testing.T) {
mf, err := NewMediaFile(filepath.Join(conf.SamplesPath(), "gopher-video.mp4"))
if err != nil {

View file

@ -64,6 +64,8 @@ type MediaFile struct {
metaOnce sync.Once
videoInfo video.Info
videoOnce sync.Once
insta360Model string
insta360Once sync.Once
fileMutex sync.Mutex
location *entity.Cell
imageConfig *image.Config
@ -932,6 +934,30 @@ func (m *MediaFile) IsInsta360() bool {
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.

View file

@ -1084,6 +1084,30 @@ func TestMediaFile_IsInsta360(t *testing.T) {
})
}
// 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")
@ -1171,6 +1195,48 @@ func copyFixture(t *testing.T, dir, name, srcName string) string {
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.

67
pkg/media/insta360.go Normal file
View file

@ -0,0 +1,67 @@
package media
import (
"bytes"
"errors"
"fmt"
"io"
"os"
)
const (
insta360MetadataScanLimit = 64 * 1024
insta360OneRSModel = "Insta360 OneRS"
insta360OneRSImageField = "\x00Arashi Vision\x00Insta360 OneRS\x00"
)
var insta360OneRSVideoField = append([]byte{0x12, byte(len(insta360OneRSModel))}, []byte(insta360OneRSModel)...)
// 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)
} else if bytes.Contains(tail, insta360OneRSVideoField) {
return insta360OneRSModel, nil
}
return "", nil
}

View file

@ -0,0 +1,76 @@
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)
})
}