mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Media: Render supported Insta360 originals as 360° media #5711
This commit is contained in:
parent
e09ad8743b
commit
26f5a6c9a1
26 changed files with 1398 additions and 50 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ func ConvertWorker(jobs <-chan ConvertJob) {
|
|||
// f is the media file to be converted.
|
||||
f := job.file
|
||||
|
||||
// A complete Insta360 capture has one canonical _00 owner. Its _10 lens and LRV proxy are
|
||||
// preserved and indexed as related originals, but must not create duplicate sidecars.
|
||||
if capture := FindInsta360Capture(f); capture != nil && capture.ValidPair() && capture.Left.FileName() != f.FileName() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case f.IsAnimated():
|
||||
// Extract metadata.
|
||||
|
|
@ -64,7 +70,7 @@ func ConvertWorker(jobs <-chan ConvertJob) {
|
|||
}
|
||||
|
||||
// Transcode to MP4 AVC.
|
||||
if _, err := job.convert.ToAvc(f, job.convert.conf.FFmpegEncoder(), false, false); err != nil {
|
||||
if _, err := job.convert.ToAvc(f, job.convert.conf.FFmpegEncoder(), false, job.force); err != nil {
|
||||
handleErr(err, job)
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
164
internal/photoprism/index_insta360.go
Normal file
164
internal/photoprism/index_insta360.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/dsn"
|
||||
)
|
||||
|
||||
var insta360ReconcileMutex sync.Mutex
|
||||
|
||||
// reconcileInsta360Photos combines previously separate capture records during a forced reindex.
|
||||
func reconcileInsta360Photos(related RelatedFiles) error {
|
||||
if related.Main == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
capture := FindInsta360Capture(related.Main)
|
||||
if capture == nil || !capture.ValidPair() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileNames := make([]string, 0, 3)
|
||||
for _, file := range capture.Files() {
|
||||
fileNames = append(fileNames, file.RootRelName())
|
||||
}
|
||||
|
||||
insta360ReconcileMutex.Lock()
|
||||
defer insta360ReconcileMutex.Unlock()
|
||||
|
||||
var indexedFiles []entity.File
|
||||
if err := entity.UnscopedDb().Where("file_root = ? AND file_name IN (?)", entity.RootOriginals, fileNames).Find(&indexedFiles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
photoIDs := make([]uint, 0, len(indexedFiles))
|
||||
seen := make(map[uint]bool, len(indexedFiles))
|
||||
for _, file := range indexedFiles {
|
||||
if file.PhotoID > 0 && !seen[file.PhotoID] {
|
||||
seen[file.PhotoID] = true
|
||||
photoIDs = append(photoIDs, file.PhotoID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(photoIDs) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var photos entity.Photos
|
||||
if err := entity.UnscopedDb().Where("id IN (?)", photoIDs).Order("id ASC").Find(&photos).Error; err != nil {
|
||||
return err
|
||||
} else if len(photos) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
canonical := photos[0]
|
||||
favorite, private, panorama, allArchived := false, false, false, true
|
||||
title, titleSrc := canonical.PhotoTitle, canonical.TitleSrc
|
||||
caption, captionSrc := canonical.PhotoCaption, canonical.CaptionSrc
|
||||
|
||||
for _, photo := range photos {
|
||||
favorite = favorite || photo.PhotoFavorite
|
||||
private = private || photo.PhotoPrivate
|
||||
panorama = panorama || photo.PhotoPanorama
|
||||
allArchived = allArchived && photo.DeletedAt != nil
|
||||
|
||||
if photo.PhotoTitle != "" && (title == "" || entity.SrcPriority[photo.TitleSrc] > entity.SrcPriority[titleSrc]) {
|
||||
title, titleSrc = photo.PhotoTitle, photo.TitleSrc
|
||||
}
|
||||
if photo.PhotoCaption != "" && (caption == "" || entity.SrcPriority[photo.CaptionSrc] > entity.SrcPriority[captionSrc]) {
|
||||
caption, captionSrc = photo.PhotoCaption, photo.CaptionSrc
|
||||
}
|
||||
}
|
||||
|
||||
values := entity.Values{
|
||||
"photo_favorite": favorite,
|
||||
"photo_private": private,
|
||||
"photo_panorama": panorama,
|
||||
"photo_title": title,
|
||||
"title_src": titleSrc,
|
||||
"photo_caption": caption,
|
||||
"caption_src": captionSrc,
|
||||
}
|
||||
if allArchived {
|
||||
values["deleted_at"] = canonical.DeletedAt
|
||||
} else {
|
||||
values["deleted_at"] = nil
|
||||
}
|
||||
|
||||
tx := entity.UnscopedDb().Begin()
|
||||
if tx.Error != nil {
|
||||
return tx.Error
|
||||
}
|
||||
|
||||
rollback := func(err error) error {
|
||||
if rollbackErr := tx.Rollback().Error; rollbackErr != nil {
|
||||
return errors.Join(err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&entity.Photo{}).Where("id = ?", canonical.ID).UpdateColumns(values).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
for _, duplicate := range photos[1:] {
|
||||
if err := tx.Model(&entity.File{}).Where("photo_id = ?", duplicate.ID).UpdateColumns(entity.Values{
|
||||
"photo_id": canonical.ID,
|
||||
"photo_uid": canonical.PhotoUID,
|
||||
"file_primary": false,
|
||||
}).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
var statements []string
|
||||
switch entity.DbDialect() {
|
||||
case dsn.DriverMySQL:
|
||||
statements = []string{
|
||||
"UPDATE IGNORE photos_keywords SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE IGNORE photos_labels SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE IGNORE photos_albums SET photo_uid = ? WHERE photo_uid = ?",
|
||||
}
|
||||
case dsn.DriverSQLite3:
|
||||
statements = []string{
|
||||
"UPDATE OR IGNORE photos_keywords SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE OR IGNORE photos_labels SET photo_id = ? WHERE photo_id = ?",
|
||||
"UPDATE OR IGNORE photos_albums SET photo_uid = ? WHERE photo_uid = ?",
|
||||
}
|
||||
default:
|
||||
return rollback(errors.New("unsupported database dialect"))
|
||||
}
|
||||
|
||||
if err := tx.Exec(statements[0], canonical.ID, duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec(statements[1], canonical.ID, duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec(statements[2], canonical.PhotoUID, duplicate.PhotoUID).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
if err := tx.Exec("DELETE FROM photos_keywords WHERE photo_id = ?", duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec("DELETE FROM photos_labels WHERE photo_id = ?", duplicate.ID).Error; err != nil {
|
||||
return rollback(err)
|
||||
} else if err = tx.Exec("DELETE FROM photos_albums WHERE photo_uid = ?", duplicate.PhotoUID).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
|
||||
if err := tx.Model(&entity.Photo{}).Where("id = ?", duplicate.ID).UpdateColumns(entity.Values{
|
||||
"photo_quality": -1,
|
||||
"deleted_at": entity.Now(),
|
||||
}).Error; err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entity.File{PhotoID: canonical.ID, PhotoUID: canonical.PhotoUID}.RegenerateIndex()
|
||||
return nil
|
||||
}
|
||||
89
internal/photoprism/index_insta360_test.go
Normal file
89
internal/photoprism/index_insta360_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// TestReconcileInsta360Photos verifies force-reindex state preservation and file reassignment.
|
||||
func TestReconcileInsta360Photos(t *testing.T) {
|
||||
t.Setenv("PHOTOPRISM_TEST_DSN", filepath.Join(t.TempDir(), "index-insta360-reconcile.db"))
|
||||
cfg := config.NewMinimalTestConfigWithDb("index-insta360-reconcile", filepath.Join(t.TempDir(), "storage"))
|
||||
oldCfg := Config()
|
||||
SetConfig(cfg)
|
||||
t.Cleanup(func() {
|
||||
SetConfig(oldCfg)
|
||||
oldCfg.RegisterDb()
|
||||
})
|
||||
|
||||
dir := filepath.Join(cfg.OriginalsPath(), "capture")
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
rightName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
proxyName := writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
related, err := left.RelatedFiles(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
photos := make([]entity.Photo, 3)
|
||||
for i := range photos {
|
||||
photos[i] = entity.NewPhoto(true)
|
||||
photos[i].PhotoTitle = ""
|
||||
require.NoError(t, photos[i].Create())
|
||||
}
|
||||
|
||||
archivedAt := entity.Now()
|
||||
photos[0].DeletedAt = &archivedAt
|
||||
photos[1].DeletedAt = &archivedAt
|
||||
photos[1].PhotoFavorite = true
|
||||
photos[2].PhotoCaption = "preserved manual caption"
|
||||
photos[2].CaptionSrc = entity.SrcManual
|
||||
for i := range photos {
|
||||
require.NoError(t, photos[i].Save())
|
||||
}
|
||||
|
||||
for i, fileName := range []string{leftName, rightName, proxyName} {
|
||||
mediaFile, mediaErr := NewMediaFile(fileName)
|
||||
require.NoError(t, mediaErr)
|
||||
file := entity.File{
|
||||
PhotoID: photos[i].ID,
|
||||
PhotoUID: photos[i].PhotoUID,
|
||||
FileName: mediaFile.RootRelName(),
|
||||
FileRoot: entity.RootOriginals,
|
||||
FileHash: mediaFile.Hash(),
|
||||
FileType: mediaFile.FileType().String(),
|
||||
MediaType: media.Video.String(),
|
||||
FileVideo: true,
|
||||
}
|
||||
require.NoError(t, file.Create())
|
||||
}
|
||||
|
||||
require.NoError(t, reconcileInsta360Photos(related))
|
||||
|
||||
var canonical entity.Photo
|
||||
require.NoError(t, entity.UnscopedDb().First(&canonical, "id = ?", photos[0].ID).Error)
|
||||
assert.True(t, canonical.PhotoFavorite)
|
||||
assert.Nil(t, canonical.DeletedAt)
|
||||
assert.Equal(t, "preserved manual caption", canonical.PhotoCaption)
|
||||
assert.Equal(t, entity.SrcManual, canonical.CaptionSrc)
|
||||
|
||||
var files []entity.File
|
||||
require.NoError(t, entity.UnscopedDb().Where("file_name IN (?)", []string{
|
||||
left.RootRelName(),
|
||||
RootRelName(rightName),
|
||||
RootRelName(proxyName),
|
||||
}).Find(&files).Error)
|
||||
require.Len(t, files, 3)
|
||||
for _, file := range files {
|
||||
assert.Equal(t, canonical.ID, file.PhotoID)
|
||||
assert.Equal(t, canonical.PhotoUID, file.PhotoUID)
|
||||
}
|
||||
}
|
||||
|
|
@ -43,8 +43,9 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
}
|
||||
|
||||
// Create JPEG sidecar for media files in other formats so that thumbnails can be created.
|
||||
if o.Convert && f.IsMedia() && !f.HasPreviewImage() {
|
||||
if img, imgErr := ind.convert.ToImage(f, false); imgErr != nil {
|
||||
forcePreview := forceDewarpPreview(f, o.Rescan)
|
||||
if o.Convert && f.IsMedia() && (!f.HasPreviewImage() || forcePreview) {
|
||||
if img, imgErr := ind.convert.ToImage(f, forcePreview); imgErr != nil {
|
||||
// Stop the run instead of masking a full disk as a generic preview error.
|
||||
if errors.Is(imgErr, status.ErrInsufficientStorage) {
|
||||
ind.abortInsufficientStorage()
|
||||
|
|
@ -84,6 +85,21 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
}
|
||||
}
|
||||
|
||||
// Generate the playable equirectangular AVC while the index worker is already running in the
|
||||
// background. This prevents the HTTP video endpoint from doing an expensive v360 conversion.
|
||||
if o.Convert && f.DewarpableInsv() {
|
||||
if avc, avcErr := ind.convert.ToAvc(f, ind.conf.FFmpegEncoder(), false, o.Rescan); errors.Is(avcErr, status.ErrInsufficientStorage) {
|
||||
ind.abortInsufficientStorage()
|
||||
result.Err = avcErr
|
||||
result.Status = IndexFailed
|
||||
return result
|
||||
} else if avcErr != nil {
|
||||
log.Warnf("index: could not create equirectangular video for %s (%s)", clean.Log(f.RootRelName()), avcErr)
|
||||
} else if avc != nil {
|
||||
related.Files = append(related.Files, avc)
|
||||
}
|
||||
}
|
||||
|
||||
// Index main MediaFile.
|
||||
exists := ind.files.Exists(f.RootRelName(), f.Root())
|
||||
result = ind.MediaFile(f, o, "", "")
|
||||
|
|
@ -108,3 +124,8 @@ func IndexMain(related *RelatedFiles, ind *Index, o IndexOptions) (result IndexR
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
// forceDewarpPreview reports whether forced indexing should replace a recognized 360° preview.
|
||||
func forceDewarpPreview(f *MediaFile, rescan bool) bool {
|
||||
return rescan && f != nil && (f.DewarpableInsv() || f.IsInsp() && f.DualFisheyeLayout() || f.FisheyeDng())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
152
internal/photoprism/mediafile_insta360.go
Normal file
152
internal/photoprism/mediafile_insta360.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
const (
|
||||
insta360PairAspectTolerance = 0.05
|
||||
insta360PairFpsTolerance = 0.1
|
||||
insta360PairDurationTolerance = time.Second
|
||||
)
|
||||
|
||||
// Insta360Capture contains the original lens files and optional low-resolution proxy for one capture.
|
||||
type Insta360Capture struct {
|
||||
Name media.Insta360VideoName
|
||||
Left *MediaFile
|
||||
Right *MediaFile
|
||||
Proxy *MediaFile
|
||||
}
|
||||
|
||||
// FindInsta360Capture resolves the files belonging to the same directory-scoped capture as f.
|
||||
func FindInsta360Capture(f *MediaFile) *Insta360Capture {
|
||||
if f == nil || !f.IsInsv() {
|
||||
return nil
|
||||
}
|
||||
|
||||
name, ok := media.ParseInsta360VideoName(f.FileName())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &Insta360Capture{Name: name}
|
||||
|
||||
for role, fileName := range map[media.Insta360VideoRole]string{
|
||||
media.Insta360VideoLeft: name.FileName(media.Insta360VideoLeft),
|
||||
media.Insta360VideoRight: name.FileName(media.Insta360VideoRight),
|
||||
media.Insta360VideoProxy: name.FileName(media.Insta360VideoProxy),
|
||||
} {
|
||||
if !fs.FileExistsNotEmpty(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
captureFile, err := NewMediaFile(fileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch role {
|
||||
case media.Insta360VideoLeft:
|
||||
result.Left = captureFile
|
||||
case media.Insta360VideoRight:
|
||||
result.Right = captureFile
|
||||
case media.Insta360VideoProxy:
|
||||
result.Proxy = captureFile
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidPair reports whether the two full-resolution lens files can safely be combined.
|
||||
func (m *Insta360Capture) ValidPair() bool {
|
||||
if m == nil || m.Left == nil || m.Right == nil || !m.Left.IsInsv() || !m.Right.IsInsv() {
|
||||
return false
|
||||
}
|
||||
|
||||
leftWidth, leftHeight := m.Left.Width(), m.Left.Height()
|
||||
rightWidth, rightHeight := m.Right.Width(), m.Right.Height()
|
||||
|
||||
if leftWidth > 0 && leftHeight > 0 && math.Abs(float64(leftWidth)/float64(leftHeight)-1) > insta360PairAspectTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if rightWidth > 0 && rightHeight > 0 && math.Abs(float64(rightWidth)/float64(rightHeight)-1) > insta360PairAspectTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftWidth > 0 && rightWidth > 0 && (leftWidth != rightWidth || leftHeight != rightHeight) {
|
||||
return false
|
||||
}
|
||||
|
||||
leftInfo, rightInfo := m.Left.VideoInfo(), m.Right.VideoInfo()
|
||||
|
||||
if leftInfo.FPS > 0 && rightInfo.FPS > 0 && math.Abs(leftInfo.FPS-rightInfo.FPS) > insta360PairFpsTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
if leftInfo.Duration > 0 && rightInfo.Duration > 0 && absDuration(leftInfo.Duration-rightInfo.Duration) > insta360PairDurationTolerance {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Files returns capture members in canonical lens and proxy order.
|
||||
func (m *Insta360Capture) Files() MediaFiles {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(MediaFiles, 0, 3)
|
||||
for _, file := range (MediaFiles{m.Left, m.Right, m.Proxy}) {
|
||||
if file != nil {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// DewarpableInsv reports whether an INSV contains or belongs to a complete dual-fisheye frame.
|
||||
func (m *MediaFile) DewarpableInsv() bool {
|
||||
if m == nil || !m.IsInsv() {
|
||||
return false
|
||||
}
|
||||
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.ValidPair() {
|
||||
return true
|
||||
}
|
||||
|
||||
return m.DualFisheyeLayout()
|
||||
}
|
||||
|
||||
// DewarpedVideoFile returns an existing equirectangular AVC for an Insta360 video.
|
||||
func DewarpedVideoFile(m *MediaFile) *MediaFile {
|
||||
if m == nil || !m.DewarpableInsv() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result := m.AvcFile(); result != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.Proxy != nil {
|
||||
return capture.Proxy.AvcFile()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// absDuration returns the absolute value of a duration.
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
145
internal/photoprism/mediafile_insta360_test.go
Normal file
145
internal/photoprism/mediafile_insta360_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media"
|
||||
)
|
||||
|
||||
// writeInsta360CaptureFile copies a square image fixture to an INSV capture filename for geometry tests.
|
||||
func writeInsta360CaptureFile(t *testing.T, dir, name, fixture string) string {
|
||||
t.Helper()
|
||||
require.NoError(t, fs.MkdirAll(dir))
|
||||
|
||||
// #nosec G304 -- the fixture path is controlled by the test.
|
||||
payload, err := os.ReadFile(fixture)
|
||||
require.NoError(t, err)
|
||||
|
||||
fileName := filepath.Join(dir, name)
|
||||
// #nosec G703 -- the destination directory and filename are controlled by the test.
|
||||
require.NoError(t, os.WriteFile(fileName, payload, fs.ModeFile))
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
// TestFindInsta360Capture verifies exact capture grouping and incomplete-pair fallback.
|
||||
func TestFindInsta360Capture(t *testing.T) {
|
||||
t.Run("CompleteWithProxy", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
capture := FindInsta360Capture(left)
|
||||
require.NotNil(t, capture)
|
||||
assert.True(t, capture.ValidPair())
|
||||
assert.Len(t, capture.Files(), 3)
|
||||
assert.Equal(t, media.Insta360VideoLeft, capture.Name.Role)
|
||||
})
|
||||
t.Run("Incomplete", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
left, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
capture := FindInsta360Capture(left)
|
||||
require.NotNil(t, capture)
|
||||
assert.False(t, capture.ValidPair())
|
||||
assert.Len(t, capture.Files(), 1)
|
||||
})
|
||||
t.Run("Unrelated", func(t *testing.T) {
|
||||
file, err := NewMediaFile("testdata/insta360.insv")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, FindInsta360Capture(file))
|
||||
})
|
||||
}
|
||||
|
||||
func TestForceDewarpPreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ordinary, err := NewMediaFile("testdata/flash.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.True(t, forceDewarpPreview(left, true))
|
||||
assert.False(t, forceDewarpPreview(left, false))
|
||||
assert.False(t, forceDewarpPreview(ordinary, true))
|
||||
assert.False(t, forceDewarpPreview(nil, true))
|
||||
}
|
||||
|
||||
// TestDewarpedVideoFile verifies direct AVC selection, LRV fallback, and fail-closed behavior.
|
||||
func TestDewarpedVideoFile(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
proxyName := writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
require.NoError(t, err)
|
||||
proxy, err := NewMediaFile(proxyName)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, DewarpedVideoFile(left))
|
||||
|
||||
proxyAvcName, err := fs.FileName(proxy.FileName(), conf.SidecarPath(), conf.OriginalsPath(), fs.ExtAvc)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(proxyAvcName) })
|
||||
videoFixture, err := NewMediaFile(conf.SamplesPath() + "/blue-go-video.mp4")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, videoFixture.Copy(proxyAvcName, false))
|
||||
proxyAvc := DewarpedVideoFile(left)
|
||||
require.NotNil(t, proxyAvc)
|
||||
assert.Equal(t, proxyAvcName, proxyAvc.FileName())
|
||||
|
||||
leftAvcName, err := fs.FileName(left.FileName(), conf.SidecarPath(), conf.OriginalsPath(), fs.ExtAvc)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(leftAvcName) })
|
||||
require.NoError(t, videoFixture.Copy(leftAvcName, false))
|
||||
leftAvc := DewarpedVideoFile(left)
|
||||
require.NotNil(t, leftAvc)
|
||||
assert.Equal(t, leftAvcName, leftAvc.FileName())
|
||||
|
||||
ordinary, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, DewarpedVideoFile(ordinary))
|
||||
assert.Nil(t, DewarpedVideoFile(nil))
|
||||
}
|
||||
|
||||
// TestInsta360Capture_ValidPair verifies geometry and timing safeguards.
|
||||
func TestInsta360Capture_ValidPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
left, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
right, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg"))
|
||||
require.NoError(t, err)
|
||||
left.width, left.height = 3072, 3072
|
||||
right.width, right.height = 3072, 3072
|
||||
capture := &Insta360Capture{Left: left, Right: right}
|
||||
|
||||
assert.True(t, capture.ValidPair())
|
||||
invalidRight, err := NewMediaFile(writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_009.insv", "testdata/2015-02-04.jpg"))
|
||||
require.NoError(t, err)
|
||||
invalidRight.width, invalidRight.height = 1920, 1080
|
||||
assert.False(t, (&Insta360Capture{Left: left, Right: invalidRight}).ValidPair())
|
||||
assert.False(t, (*Insta360Capture)(nil).ValidPair())
|
||||
}
|
||||
|
||||
// TestAbsDuration verifies duration normalization.
|
||||
func TestAbsDuration(t *testing.T) {
|
||||
assert.Equal(t, absDuration(-5), absDuration(5))
|
||||
}
|
||||
79
internal/photoprism/mediafile_projection.go
Normal file
79
internal/photoprism/mediafile_projection.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// SetVisualProjection records the projection produced by a successful in-process conversion.
|
||||
func (m *MediaFile) SetVisualProjection(value projection.Type) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.visualProjection = value
|
||||
}
|
||||
|
||||
// VisualProjection returns an explicit, metadata, or safely inferred projection for the file.
|
||||
func (m *MediaFile) VisualProjection(metadataValue string) projection.Type {
|
||||
if m == nil {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
if !m.visualProjection.Unknown() {
|
||||
return m.visualProjection
|
||||
}
|
||||
|
||||
if value := projection.New(metadataValue); !value.Unknown() {
|
||||
return value
|
||||
}
|
||||
|
||||
return m.derivedVisualProjection()
|
||||
}
|
||||
|
||||
// derivedVisualProjection recognizes generated 2:1 sidecars whose original source requires dewarping.
|
||||
func (m *MediaFile) derivedVisualProjection() projection.Type {
|
||||
if m == nil || !m.InSidecar() || !m.DualFisheyeLayout() {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
var generatedExt string
|
||||
switch m.FileType() {
|
||||
case fs.ImageJpeg:
|
||||
generatedExt = fs.ExtJpeg
|
||||
case fs.VideoAvc:
|
||||
generatedExt = fs.ExtAvc
|
||||
default:
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
relName := m.RelName(Config().SidecarPath())
|
||||
if !strings.EqualFold(filepath.Ext(relName), generatedExt) {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
sourceRelName := strings.TrimSuffix(relName, filepath.Ext(relName))
|
||||
source, err := NewMediaFile(filepath.Join(Config().OriginalsPath(), sourceRelName))
|
||||
if err != nil || source == nil {
|
||||
return projection.Unknown
|
||||
}
|
||||
|
||||
switch {
|
||||
case source.IsInsp() && source.DualFisheyeLayout():
|
||||
return projection.Equirectangular
|
||||
case source.IsInsv():
|
||||
if capture := FindInsta360Capture(source); capture != nil && capture.ValidPair() {
|
||||
return projection.Equirectangular
|
||||
}
|
||||
if source.DualFisheyeLayout() {
|
||||
return projection.Equirectangular
|
||||
}
|
||||
case source.FisheyeDng():
|
||||
return projection.Equirectangular
|
||||
}
|
||||
|
||||
return projection.Unknown
|
||||
}
|
||||
53
internal/photoprism/mediafile_projection_test.go
Normal file
53
internal/photoprism/mediafile_projection_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/pkg/media/projection"
|
||||
)
|
||||
|
||||
// TestMediaFile_VisualProjection verifies explicit, metadata, and generated-sidecar projection paths.
|
||||
func TestMediaFile_VisualProjection(t *testing.T) {
|
||||
t.Run("Explicit", func(t *testing.T) {
|
||||
file := &MediaFile{}
|
||||
file.SetVisualProjection(projection.Equirectangular)
|
||||
assert.Equal(t, projection.Equirectangular, file.VisualProjection(""))
|
||||
})
|
||||
t.Run("Metadata", func(t *testing.T) {
|
||||
file := &MediaFile{}
|
||||
assert.Equal(t, projection.Cubestrip, file.VisualProjection(projection.Cubestrip.String()))
|
||||
})
|
||||
t.Run("GeneratedInspSidecar", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
dir := "projection-persistence"
|
||||
source, err := NewMediaFile("testdata/insta360.insp")
|
||||
require.NoError(t, err)
|
||||
originalName := filepath.Join(conf.OriginalsPath(), dir, "camera.insp")
|
||||
require.NoError(t, source.Copy(originalName, false))
|
||||
|
||||
preview, err := NewMediaFile("testdata/insta360.insp.jpg")
|
||||
require.NoError(t, err)
|
||||
previewName := filepath.Join(conf.SidecarPath(), dir, "camera.insp.jpg")
|
||||
require.NoError(t, preview.Copy(previewName, false))
|
||||
|
||||
generated, err := NewMediaFile(previewName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, projection.Equirectangular, generated.VisualProjection(""))
|
||||
})
|
||||
t.Run("OrdinarySidecar", func(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
preview, err := NewMediaFile("testdata/flash.jpg")
|
||||
require.NoError(t, err)
|
||||
previewName := filepath.Join(conf.SidecarPath(), "ordinary.jpg")
|
||||
require.NoError(t, preview.Copy(previewName, false))
|
||||
|
||||
generated, err := NewMediaFile(previewName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, projection.Unknown, generated.VisualProjection(""))
|
||||
})
|
||||
}
|
||||
|
|
@ -59,10 +59,32 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
|
|||
matches = list.Join(matches, files)
|
||||
}
|
||||
|
||||
// Insta360 cameras may store one capture as two full-resolution lens videos plus an optional
|
||||
// low-resolution proxy whose filename has a different prefix. Include the complete capture and
|
||||
// all of its sidecars so the indexer creates one photo instead of three unrelated records.
|
||||
var captureMain string
|
||||
if capture := FindInsta360Capture(m); capture != nil && capture.ValidPair() {
|
||||
captureMain = capture.Left.FileName()
|
||||
|
||||
for _, captureFile := range capture.Files() {
|
||||
capturePattern := regexp.QuoteMeta(captureFile.AbsPrefix(false)+".") + "*"
|
||||
if captureMatches, captureErr := filepath.Glob(capturePattern); captureErr == nil {
|
||||
matches = list.Join(matches, captureMatches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHeic := false
|
||||
|
||||
processedMatches := make(map[string]bool, len(matches))
|
||||
|
||||
// Process files that matched the pattern.
|
||||
for _, fileName := range matches {
|
||||
if processedMatches[fileName] {
|
||||
continue
|
||||
}
|
||||
|
||||
processedMatches[fileName] = true
|
||||
f, fileErr := NewMediaFile(fileName)
|
||||
|
||||
if fileErr != nil || f.Empty() || f.IsArchive() {
|
||||
|
|
@ -133,6 +155,17 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
|
|||
|
||||
sort.Sort(result.Files)
|
||||
|
||||
// The left (_00) lens is the canonical main original for a complete Insta360 capture. The
|
||||
// generated equirectangular preview becomes the primary display file later during indexing.
|
||||
if captureMain != "" {
|
||||
for _, file := range result.Files {
|
||||
if file.FileName() == captureMain {
|
||||
result.Main = file
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -141,6 +142,30 @@ func TestMediaFile_RelatedFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
})
|
||||
t.Run("Insta360Capture", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
leftName := writeInsta360CaptureFile(t, dir, "VID_20220625_140410_00_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "VID_20220625_140410_10_008.insv", "testdata/flash.jpg")
|
||||
writeInsta360CaptureFile(t, dir, "LRV_20220625_140410_11_008.insv", "testdata/flash.jpg")
|
||||
|
||||
left, err := NewMediaFile(leftName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
related, err := left.RelatedFiles(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Len(t, related.Files, 3)
|
||||
assert.Equal(t, filepath.Base(leftName), related.Main.BaseName())
|
||||
assert.ElementsMatch(t, []string{
|
||||
"VID_20220625_140410_00_008.insv",
|
||||
"VID_20220625_140410_10_008.insv",
|
||||
"LRV_20220625_140410_11_008.insv",
|
||||
}, []string{related.Files[0].BaseName(), related.Files[1].BaseName(), related.Files[2].BaseName()})
|
||||
})
|
||||
t.Run("Num2015Num02Num04Jpg", func(t *testing.T) {
|
||||
mediaFile, err := NewMediaFile("testdata/2015-02-04.jpg")
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue