diff --git a/assets b/assets index 44a696a..0fa5754 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 44a696a2e7271bb6b98424670fe93c3df4ebc10b +Subproject commit 0fa57541d5b1018251674ef01a7e799f6899564e diff --git a/inventory/setting.go b/inventory/setting.go index b9139ad..c08a203 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -82,7 +82,7 @@ func (c *settingClient) Set(ctx context.Context, settings map[string]string) err var ( defaultIcons = []types.FileTypeIconSetting{ { - Exts: []string{"mp3", "flac", "ape", "wav", "acc", "ogg", "m4a", "aac"}, + Exts: []string{"mp3", "flac", "ape", "wav", "acc", "ogg", "m4a"}, Icon: "audio", Color: "#651fff", }, @@ -122,7 +122,7 @@ var ( Icon: "excel", }, { - Exts: []string{"txt", "html", "ini", "env", "json", "log", "yml"}, + Exts: []string{"txt", "html"}, Color: "#607d8b", Icon: "text", }, @@ -132,7 +132,7 @@ var ( Icon: "torrent", }, { - Exts: []string{"zip", "gz", "xz", "tar", "rar", "7z", "bz2", "z", "iso"}, + Exts: []string{"zip", "gz", "xz", "tar", "rar", "7z", "bz2", "z"}, Color: "#f9a825", Icon: "zip", }, @@ -211,7 +211,7 @@ var ( ID: "music", Type: types.ViewerTypeBuiltin, DisplayName: "fileManager.musicPlayer", - Exts: []string{"mp3", "ogg", "wav", "flac", "m4a", "aac"}, + Exts: []string{"mp3", "ogg", "wav", "flac", "m4a"}, }, { ID: "epub", @@ -293,7 +293,7 @@ var ( Type: types.ViewerTypeBuiltin, Icon: "/static/img/viewers/monaco.svg", DisplayName: "fileManager.monacoEditor", - Exts: []string{"md", "txt", "json", "php", "py", "bat", "c", "h", "cpp", "hpp", "cs", "css", "dockerfile", "go", "html", "htm", "ini", "java", "js", "jsx", "less", "lua", "sh", "sql", "xml", "yaml", "ts", "tsx", "yml", "vue", "env", "log"}, + Exts: []string{"md", "txt", "json", "php", "py", "bat", "c", "h", "cpp", "hpp", "cs", "css", "dockerfile", "go", "html", "htm", "ini", "java", "js", "jsx", "less", "lua", "sh", "sql", "xml", "yaml"}, Templates: []types.NewFileTemplate{ { Ext: "txt", @@ -406,7 +406,7 @@ var DefaultSettings = map[string]string{ "thumb_builtin_max_size": "78643200", // 75 MB "thumb_vips_max_size": "78643200", // 75 MB "thumb_vips_enabled": "0", - "thumb_vips_exts": "3fr,ari,arw,bay,braw,crw,cr2,cr3,cap,data,dcs,dcr,dng,drf,eip,erf,fff,gpr,iiq,k25,kdc,mdc,mef,mos,mrw,nef,nrw,obm,orf,pef,ptx,pxn,r3d,raf,raw,rwl,rw2,rwz,sr2,srf,srw,tif,x3f,csv,mat,img,hdr,pbm,pgm,ppm,pfm,pnm,svg,svgz,j2k,jp2,jpt,j2c,jpc,gif,png,jpg,jpeg,jpe,webp,tif,tiff,fits,fit,fts,exr,jxl,pdf,heic,heif,avif,svs,vms,vmu,ndpi,scn,mrxs,svslide,bif,raw,ico,icns", + "thumb_vips_exts": "3fr,ari,arw,bay,braw,crw,cr2,cr3,cap,data,dcs,dcr,dng,drf,eip,erf,fff,gpr,iiq,k25,kdc,mdc,mef,mos,mrw,nef,nrw,obm,orf,pef,ptx,pxn,r3d,raf,raw,rwl,rw2,rwz,sr2,srf,srw,tif,x3f,csv,mat,img,hdr,pbm,pgm,ppm,pfm,pnm,svg,svgz,j2k,jp2,jpt,j2c,jpc,gif,png,jpg,jpeg,jpe,webp,tif,tiff,fits,fit,fts,exr,jxl,pdf,heic,heif,avif,svs,vms,vmu,ndpi,scn,mrxs,svslide,bif,raw", "thumb_ffmpeg_enabled": "0", "thumb_vips_path": "vips", "thumb_ffmpeg_path": "ffmpeg", @@ -420,6 +420,10 @@ var DefaultSettings = map[string]string{ "thumb_music_cover_enabled": "1", "thumb_music_cover_exts": "mp3,m4a,ogg,flac", "thumb_music_cover_max_size": "1073741824", // 1 GB + "thumb_libraw_enabled": "0", + "thumb_libraw_path": "simple_dcraw", + "thumb_libraw_max_size": "78643200", // 75 MB + "thumb_libraw_exts": "3fr,ari,arw,bay,braw,crw,cr2,cr3,cap,data,dcs,dcr,dng,drf,eip,erf,fff,gpr,iiq,k25,kdc,mdc,mef,mos,mrw,nef,nrw,obm,orf,pef,ptx,pxn,r3d,raf,raw,rwl,rw2,rwz,sr2,srf,srw,tif,x3f", "phone_required": "false", "phone_enabled": "false", "show_app_promotion": "1", diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index b3005e3..334d93d 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" "fmt" - "github.com/cloudreve/Cloudreve/v4/inventory/types" "net/url" "strconv" "strings" "time" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth/requestinfo" "github.com/cloudreve/Cloudreve/v4/pkg/boolset" ) @@ -187,6 +188,14 @@ type ( AvatarProcess(ctx context.Context) *AvatarProcess // UseFirstSiteUrl returns the first site URL. AllSiteURLs(ctx context.Context) []*url.URL + // LibRawThumbGeneratorEnabled returns true if libraw thumb generator is enabled. + LibRawThumbGeneratorEnabled(ctx context.Context) bool + // LibRawThumbMaxSize returns the maximum size of libraw thumb generator. + LibRawThumbMaxSize(ctx context.Context) int64 + // LibRawThumbExts returns the supported extensions of libraw thumb generator. + LibRawThumbExts(ctx context.Context) []string + // LibRawThumbPath returns the path of libraw executable. + LibRawThumbPath(ctx context.Context) string } UseFirstSiteUrlCtxKey = struct{} ) @@ -387,6 +396,22 @@ func (s *settingProvider) VipsPath(ctx context.Context) string { return s.getString(ctx, "thumb_vips_path", "vips") } +func (s *settingProvider) LibRawThumbGeneratorEnabled(ctx context.Context) bool { + return s.getBoolean(ctx, "thumb_libraw_enabled", false) +} + +func (s *settingProvider) LibRawThumbMaxSize(ctx context.Context) int64 { + return s.getInt64(ctx, "thumb_libraw_max_size", 78643200) +} + +func (s *settingProvider) LibRawThumbExts(ctx context.Context) []string { + return s.getStringList(ctx, "thumb_libraw_exts", []string{}) +} + +func (s *settingProvider) LibRawThumbPath(ctx context.Context) string { + return s.getString(ctx, "thumb_libraw_path", "simple_dcraw") +} + func (s *settingProvider) LibreOfficeThumbGeneratorEnabled(ctx context.Context) bool { return s.getBoolean(ctx, "thumb_libreoffice_enabled", false) } diff --git a/pkg/thumb/libraw.go b/pkg/thumb/libraw.go new file mode 100644 index 0000000..e43ba8b --- /dev/null +++ b/pkg/thumb/libraw.go @@ -0,0 +1,261 @@ +package thumb + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gofrs/uuid" +) + +func NewLibRawGenerator(l logging.Logger, settings setting.Provider) *LibRawGenerator { + return &LibRawGenerator{l: l, settings: settings} +} + +type LibRawGenerator struct { + l logging.Logger + settings setting.Provider +} + +func (l *LibRawGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { + if !util.IsInExtensionListExt(l.settings.LibRawThumbExts(ctx), ext) { + return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough) + } + + if es.Entity().Size() > l.settings.LibRawThumbMaxSize(ctx) { + return nil, fmt.Errorf("file is too big: %w", ErrPassThrough) + } + + // If download/copy files to temp folder + tempFolder := filepath.Join( + util.DataPath(l.settings.TempPath(ctx)), + "thumb", + fmt.Sprintf("libraw_%s", uuid.Must(uuid.NewV4()).String()), + ) + tempInputFileName := fmt.Sprintf("libraw_%s.%s", uuid.Must(uuid.NewV4()).String(), ext) + tempPath := filepath.Join(tempFolder, tempInputFileName) + tempInputFile, err := util.CreatNestedFile(tempPath) + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + + defer os.Remove(tempPath) + defer tempInputFile.Close() + + if _, err = io.Copy(tempInputFile, es); err != nil { + return &Result{Path: tempPath}, fmt.Errorf("failed to write input file: %w", err) + } + + tempInputFile.Close() + + cmd := exec.CommandContext(ctx, + l.settings.LibRawThumbPath(ctx), "-e", tempPath) + + // Redirect IO + var dcrawErr bytes.Buffer + cmd.Stderr = &dcrawErr + + if err := cmd.Run(); err != nil { + l.l.Warning("Failed to invoke dcraw: %s", dcrawErr.String()) + return &Result{Path: tempPath}, fmt.Errorf("failed to invoke dcraw: %w, raw output: %s", err, dcrawErr.String()) + } + + return &Result{ + Path: filepath.Join( + tempFolder, + tempInputFileName+".thumb.jpg", + ), + Continue: true, + Cleanup: []func(){func() { _ = os.RemoveAll(tempFolder) }}, + }, nil +} + +func (l *LibRawGenerator) Priority() int { + return 50 +} + +func (l *LibRawGenerator) Enabled(ctx context.Context) bool { + return l.settings.LibRawThumbGeneratorEnabled(ctx) +} + +func rotateImg(filePath string, orientation int) error { + resultImg, err := os.OpenFile(filePath, os.O_RDWR, 0777) + if err != nil { + return err + } + defer func() { _ = resultImg.Close() }() + + imgFlag := make([]byte, 3) + if _, err = io.ReadFull(resultImg, imgFlag); err != nil { + return err + } + if _, err = resultImg.Seek(0, 0); err != nil { + return err + } + + var img image.Image + if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) { + img, err = jpeg.Decode(resultImg) + } else { + img, err = png.Decode(resultImg) + } + if err != nil { + return err + } + + switch orientation { + case 8: + img = rotate90(img) + case 3: + img = rotate90(rotate90(img)) + case 6: + img = rotate90(rotate90(rotate90(img))) + case 2: + img = mirrorImg(img) + case 7: + img = rotate90(mirrorImg(img)) + case 4: + img = rotate90(rotate90(mirrorImg(img))) + case 5: + img = rotate90(rotate90(rotate90(mirrorImg(img)))) + } + + if err = resultImg.Truncate(0); err != nil { + return err + } + if _, err = resultImg.Seek(0, 0); err != nil { + return err + } + + if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) { + return jpeg.Encode(resultImg, img, nil) + } + return png.Encode(resultImg, img) +} + +func getJpegOrientation(fileName string) (int, error) { + f, err := os.Open(fileName) + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + + header := make([]byte, 6) + defer func() { header = nil }() + if _, err = io.ReadFull(f, header); err != nil { + return 0, err + } + + // jpeg format header + if !bytes.Equal(header[:3], []byte{0xFF, 0xD8, 0xFF}) { + return 0, errors.New("not a jpeg") + } + + // not a APP1 marker + if header[3] != 0xE1 { + return 1, nil + } + + // exif data total length + totalLen := int(header[4])<<8 + int(header[5]) - 2 + buf := make([]byte, totalLen) + defer func() { buf = nil }() + if _, err = io.ReadFull(f, buf); err != nil { + return 0, err + } + + // remove Exif identifier code + buf = buf[6:] + + // byte order + parse16, parse32, err := initParseMethod(buf[:2]) + if err != nil { + return 0, err + } + + // version + _ = buf[2:4] + + // first IFD offset + offset := parse32(buf[4:8]) + + // first DE offset + offset += 2 + buf = buf[offset:] + + const ( + orientationTag = 0x112 + deEntryLength = 12 + ) + for len(buf) > deEntryLength { + tag := parse16(buf[:2]) + if tag == orientationTag { + return int(parse32(buf[8:12])), nil + } + buf = buf[deEntryLength:] + } + + return 0, errors.New("orientation not found") +} + +func initParseMethod(buf []byte) (func([]byte) int16, func([]byte) int32, error) { + if bytes.Equal(buf, []byte{0x49, 0x49}) { + return littleEndian16, littleEndian32, nil + } + if bytes.Equal(buf, []byte{0x4D, 0x4D}) { + return bigEndian16, bigEndian32, nil + } + return nil, nil, errors.New("invalid byte order") +} + +func littleEndian16(buf []byte) int16 { + return int16(buf[0]) | int16(buf[1])<<8 +} + +func bigEndian16(buf []byte) int16 { + return int16(buf[1]) | int16(buf[0])<<8 +} + +func littleEndian32(buf []byte) int32 { + return int32(buf[0]) | int32(buf[1])<<8 | int32(buf[2])<<16 | int32(buf[3])<<24 +} + +func bigEndian32(buf []byte) int32 { + return int32(buf[3]) | int32(buf[2])<<8 | int32(buf[1])<<16 | int32(buf[0])<<24 +} + +func rotate90(img image.Image) image.Image { + bounds := img.Bounds() + width, height := bounds.Dx(), bounds.Dy() + newImg := image.NewRGBA(image.Rect(0, 0, height, width)) + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + newImg.Set(y, width-x-1, img.At(x, y)) + } + } + return newImg +} + +func mirrorImg(img image.Image) image.Image { + bounds := img.Bounds() + width, height := bounds.Dx(), bounds.Dy() + newImg := image.NewRGBA(image.Rect(0, 0, width, height)) + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + newImg.Set(width-x-1, y, img.At(x, y)) + } + } + return newImg +} diff --git a/pkg/thumb/pipeline.go b/pkg/thumb/pipeline.go index bf9bbab..0b8f82d 100644 --- a/pkg/thumb/pipeline.go +++ b/pkg/thumb/pipeline.go @@ -4,14 +4,15 @@ import ( "context" "errors" "fmt" + "io" + "reflect" + "sort" + "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/cloudreve/Cloudreve/v4/pkg/util" - "io" - "reflect" - "sort" ) type ( @@ -71,6 +72,7 @@ func NewPipeline(settings setting.Provider, l logging.Logger) Generator { NewVipsGenerator(l, settings), NewLibreOfficeGenerator(l, settings), NewMusicCoverGenerator(l, settings), + NewLibRawGenerator(l, settings), ) sort.Sort(generators) diff --git a/pkg/thumb/tester.go b/pkg/thumb/tester.go index 6439c6a..e8f08c6 100644 --- a/pkg/thumb/tester.go +++ b/pkg/thumb/tester.go @@ -25,6 +25,8 @@ func TestGenerator(ctx context.Context, name, executable string) (string, error) return testLibreOfficeGenerator(ctx, executable) case "ffprobe": return testFFProbeGenerator(ctx, executable) + case "libraw": + return testLibRawGenerator(ctx, executable) default: return "", ErrUnknownGenerator } @@ -89,3 +91,20 @@ func testLibreOfficeGenerator(ctx context.Context, executable string) (string, e return output.String(), nil } + +func testLibRawGenerator(ctx context.Context, executable string) (string, error) { + cmd := exec.CommandContext(ctx, executable, "-L") + var output bytes.Buffer + cmd.Stdout = &output + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to invoke libraw executable: %w", err) + } + + if !strings.Contains(output.String(), "Sony") { + return "", ErrUnknownOutput + } + + cameraList := strings.Split(output.String(), "\n") + + return fmt.Sprintf("N/A, %d cameras supported", len(cameraList)), nil +}