diff --git a/Dockerfile b/Dockerfile index d8526bb0..92d421d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,24 +11,35 @@ ENV UI_PUBLIC_URL=${UI_PUBLIC_URL:-/} RUN mkdir -p /app WORKDIR /app +# Download dependencies COPY ui/package*.json /app/ RUN npm install COPY ui /app +# Build frontend RUN npm run build -- --public-url $UI_PUBLIC_URL # Build API FROM golang:alpine AS api +RUN mkdir -p /app WORKDIR /app + +# Download dependencies +COPY api/go.mod api/go.sum /app/ +RUN go mod download + +# Copy api source COPY api /app -RUN go get -d -v ./... RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o photoview . # Copy api and ui to production environment FROM alpine:latest +# Install darktable +RUN apk add darktable + COPY --from=ui /app/dist /ui COPY --from=api /app/database/migrations /database/migrations COPY --from=api /app/photoview /app/photoview diff --git a/api/go.mod b/api/go.mod index c449601a..2cc7e61d 100644 --- a/api/go.mod +++ b/api/go.mod @@ -16,7 +16,6 @@ require ( github.com/gorilla/websocket v1.4.1 github.com/h2non/filetype v1.0.12 github.com/joho/godotenv v1.3.0 - github.com/nf/cr2 v0.0.0-20180623103828-4699471a17ed github.com/opencontainers/go-digest v1.0.0-rc1 // indirect github.com/pkg/errors v0.8.1 github.com/urfave/cli v1.22.3 // indirect diff --git a/api/scanner/album_scanner.go b/api/scanner/album_scanner.go index 3421a0e8..a07cce5b 100644 --- a/api/scanner/album_scanner.go +++ b/api/scanner/album_scanner.go @@ -4,7 +4,6 @@ import ( "container/list" "database/sql" "fmt" - "io" "io/ioutil" "log" "os" @@ -13,7 +12,6 @@ import ( "strings" "time" - "github.com/h2non/filetype" "github.com/viktorstrate/photoview/api/graphql/models" "github.com/viktorstrate/photoview/api/graphql/notification" "github.com/viktorstrate/photoview/api/utils" @@ -21,7 +19,7 @@ import ( type scanner_cache map[string]interface{} -func (cache *scanner_cache) insert_photo_type(path string, content_type string) { +func (cache *scanner_cache) insert_photo_type(path string, content_type ImageType) { (*cache)["photo_type//"+path] = content_type } @@ -311,59 +309,6 @@ func directoryContainsPhotos(rootPath string, cache *scanner_cache) bool { return false } -var SupportedMimetypes = [...]string{ - "image/jpeg", - "image/png", - "image/tiff", - "image/webp", - "image/x-canon-cr2", - "image/bmp", -} - -var WebMimetypes = [...]string{ - "image/jpeg", - "image/png", - "image/webp", - "image/bmp", -} - -func isPathImage(path string, cache *scanner_cache) bool { - if cache.get_photo_type(path) != nil { - return true - } - file, err := os.Open(path) - if err != nil { - ScannerError("Could not open file %s: %s\n", path, err) - return false - } - defer file.Close() - - head := make([]byte, 261) - if _, err := file.Read(head); err != nil { - if err == io.EOF { - return false - } - - ScannerError("Could not read file %s: %s\n", path, err) - return false - } - - imgType, err := filetype.Image(head) - if err != nil { - return false - } - - for _, supported_mime := range SupportedMimetypes { - if supported_mime == imgType.MIME.Value { - cache.insert_photo_type(path, supported_mime) - return true - } - } - - log.Printf("Unsupported image %s of type %s\n", path, imgType.MIME.Value) - return false -} - func processUnprocessedPhotos(database *sql.DB, user *models.User, notifyKey string) error { processKey := utils.GenerateToken() diff --git a/api/scanner/encode_photo.go b/api/scanner/encode_photo.go new file mode 100644 index 00000000..3ba871b9 --- /dev/null +++ b/api/scanner/encode_photo.go @@ -0,0 +1,218 @@ +package scanner + +import ( + "database/sql" + "image" + "image/jpeg" + "os" + + "github.com/disintegration/imaging" + "github.com/pkg/errors" + "github.com/viktorstrate/photoview/api/graphql/models" + "github.com/viktorstrate/photoview/api/utils" +) + +type PhotoDimensions struct { + Width int + Height int +} + +func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions { + return PhotoDimensions{ + Width: rect.Bounds().Max.X, + Height: rect.Bounds().Max.Y, + } +} + +func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions { + aspect := float64(dimensions.Width) / float64(dimensions.Height) + + var width, height int + + if aspect > 1 { + width = 1024 + height = int(1024 / aspect) + } else { + width = int(1024 * aspect) + height = 1024 + } + + return PhotoDimensions{ + Width: width, + Height: height, + } +} + +// EncodeImageData is used to easily decode image data, with a cache so expensive operations are not repeated +type EncodeImageData struct { + photo *models.Photo + _photoImage image.Image + _thumbnailImage image.Image + _contentType *ImageType +} + +func EncodeImageJPEG(image image.Image, outputPath string, jpegQuality int) error { + photo_file, err := os.Create(outputPath) + if err != nil { + return errors.Wrapf(err, "could not create file: %s", outputPath) + } + defer photo_file.Close() + + err = jpeg.Encode(photo_file, image, &jpeg.Options{Quality: jpegQuality}) + if err != nil { + return err + } + + return nil +} + +func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) { + photoFile, err := os.Open(imagePath) + if err != nil { + return nil, err + } + defer photoFile.Close() + + config, _, err := image.DecodeConfig(photoFile) + if err != nil { + return nil, err + } + + return &PhotoDimensions{ + Width: config.Width, + Height: config.Height, + }, nil +} + +// ContentType reads the image to determine its content type +func (img *EncodeImageData) ContentType() (*ImageType, error) { + if img._contentType != nil { + return img._contentType, nil + } + + imgType, err := getImageType(img.photo.Path) + if err != nil { + return nil, err + } + + img._contentType = imgType + return imgType, nil +} + +func (img *EncodeImageData) EncodeHighRes(tx *sql.Tx, outputPath string) error { + contentType, err := img.ContentType() + if err != nil { + return err + } + + if !contentType.isSupported() { + return errors.New("could not convert photo as file format is not supported") + } + + if contentType.isRaw() { + if DarktableCli.IsInstalled() { + err := DarktableCli.EncodeJpeg(img.photo.Path, outputPath, 70) + if err != nil { + return err + } + } else { + return errors.New("could not convert photo as no RAW converter was found") + } + } else { + image, err := img.photoImage(tx) + if err != nil { + return err + } + + EncodeImageJPEG(image, outputPath, 70) + } + + return nil +} + +func EncodeThumbnail(inputPath string, outputPath string) (*PhotoDimensions, error) { + inputFile, err := os.Open(inputPath) + if err != nil { + return nil, err + } + defer inputFile.Close() + + inputImage, _, err := image.Decode(inputFile) + if err != nil { + return nil, err + } + + dimensions := PhotoDimensionsFromRect(inputImage.Bounds()) + dimensions = dimensions.ThumbnailScale() + + thumbImage := imaging.Resize(inputImage, dimensions.Width, dimensions.Height, imaging.NearestNeighbor) + if err = EncodeImageJPEG(thumbImage, outputPath, 60); err != nil { + return nil, err + } + + return &dimensions, nil +} + +// PhotoImage reads and decodes the image file and saves it in a cache so the photo in only decoded once +func (img *EncodeImageData) photoImage(tx *sql.Tx) (image.Image, error) { + if img._photoImage != nil { + return img._photoImage, nil + } + + photoFile, err := os.Open(img.photo.Path) + if err != nil { + return nil, err + } + defer photoFile.Close() + + photoImg, _, err := image.Decode(photoFile) + if err != nil { + return nil, utils.HandleError("image decoding", err) + } + + // Get orientation from exif data + row := tx.QueryRow("SELECT photo_exif.orientation FROM photo JOIN photo_exif WHERE photo.exif_id = photo_exif.exif_id AND photo.photo_id = ?", img.photo.PhotoID) + var orientation *int + if err = row.Scan(&orientation); err != nil { + // If not found use default orientation (not rotate) + if err == sql.ErrNoRows { + orientation = nil + } else { + return nil, err + } + } + + if orientation == nil { + defaultOrientation := 0 + orientation = &defaultOrientation + } + + switch *orientation { + case 2: + photoImg = imaging.FlipH(photoImg) + break + case 3: + photoImg = imaging.Rotate180(photoImg) + break + case 4: + photoImg = imaging.FlipV(photoImg) + break + case 5: + photoImg = imaging.Transpose(photoImg) + break + case 6: + photoImg = imaging.Rotate270(photoImg) + break + case 7: + photoImg = imaging.Transverse(photoImg) + break + case 8: + photoImg = imaging.Rotate90(photoImg) + break + default: + break + } + + img._photoImage = photoImg + return img._photoImage, nil +} diff --git a/api/scanner/executable_worker.go b/api/scanner/executable_worker.go new file mode 100644 index 00000000..b0f1224c --- /dev/null +++ b/api/scanner/executable_worker.go @@ -0,0 +1,60 @@ +package scanner + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + + "github.com/pkg/errors" +) + +type DarktableWorker struct { + path string +} + +func newDarktableWorker() DarktableWorker { + path, err := exec.LookPath("darktable-cli") + if err != nil { + log.Println("Executable worker not found: darktable") + } else { + log.Println("Found executable worker: darktable") + } + + return DarktableWorker{ + path: path, + } +} + +func (worker *DarktableWorker) IsInstalled() bool { + return worker.path != "" +} + +func (worker *DarktableWorker) EncodeJpeg(inputPath string, outputPath string, jpegQuality int) error { + tmpDir, err := ioutil.TempDir("/tmp", "photoview-darktable") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + args := []string{ + inputPath, + outputPath, + "--core", + "--conf", + fmt.Sprintf("plugins/imageio/format/jpeg/quality=%d", jpegQuality), + "--configdir", + tmpDir, + } + + cmd := exec.Command(worker.path, args...) + + if err := cmd.Run(); err != nil { + return errors.Wrapf(err, "encoding image using: %s %v", worker.path, args) + } + + return nil +} + +var DarktableCli = newDarktableWorker() diff --git a/api/scanner/photo_type.go b/api/scanner/photo_type.go new file mode 100644 index 00000000..928c53d2 --- /dev/null +++ b/api/scanner/photo_type.go @@ -0,0 +1,261 @@ +package scanner + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/h2non/filetype" + "github.com/pkg/errors" +) + +type ImageType string + +const ( + TypeJpeg ImageType = "image/jpeg" + TypePng ImageType = "image/png" + TypeTiff ImageType = "image/tiff" + TypeWebp ImageType = "image/webp" + TypeBmp ImageType = "image/bmp" + + // Raw formats + TypeDNG ImageType = "image/x-adobe-dng" + TypeARW ImageType = "image/x-sony-arw" + TypeSR2 ImageType = "image/x-sony-sr2" + TypeSRF ImageType = "image/x-sony-srf" + TypeCR2 ImageType = "image/x-canon-cr2" + TypeCRW ImageType = "image/x-canon-crw" + TypeERF ImageType = "image/x-epson-erf" + TypeDCS ImageType = "image/x-kodak-dcs" + TypeDRF ImageType = "image/x-kodak-drf" + TypeDCR ImageType = "image/x-kodak-dcr" + TypeK25 ImageType = "image/x-kodak-k25" + TypeKDC ImageType = "image/x-kodak-kdc" + TypeMRW ImageType = "image/x-minolta-mrw" + TypeMDC ImageType = "image/x-minolta-mdc" + TypeNEF ImageType = "image/x-nikon-nef" + TypeNRW ImageType = "image/x-nikon-nrw" + TypeORF ImageType = "image/x-olympus-orf" + TypePEF ImageType = "image/x-pentax-pef" + TypeRAF ImageType = "image/x-fuji-raf" + TypeRAW ImageType = "image/x-panasonic-raw" + TypeRW2 ImageType = "image/x-panasonic-rw2" + TypeGPR ImageType = "image/x-gopro-gpr" + Type3FR ImageType = "image/x-hasselblad-3fr" + TypeFFF ImageType = "image/x-hasselblad-fff" + TypeMEF ImageType = "image/x-mamiya-mef" + TypeCap ImageType = "image/x-phaseone-cap" + TypeIIQ ImageType = "image/x-phaseone-iiq" + TypeMOS ImageType = "image/x-leaf-mos" + TypeRWL ImageType = "image/x-leica-rwl" + TypeSRW ImageType = "image/x-samsung-srw" +) + +var SupportedMimetypes = [...]ImageType{ + TypeJpeg, + TypePng, + TypeTiff, + TypeWebp, + TypeBmp, + + TypeDNG, + TypeARW, + TypeSR2, + TypeSRF, + TypeCR2, + TypeCRW, + TypeERF, + TypeDCS, + TypeDRF, + TypeDCR, + TypeK25, + TypeKDC, + TypeMRW, + TypeMDC, + TypeNEF, + TypeNRW, + TypeORF, + TypePEF, + TypeRAF, + TypeRAW, + TypeRW2, + TypeGPR, + Type3FR, + TypeFFF, + TypeMEF, + TypeCap, + TypeIIQ, + TypeMOS, + TypeRWL, + TypeSRW, +} + +var WebMimetypes = [...]ImageType{ + TypeJpeg, + TypePng, + TypeWebp, + TypeBmp, +} + +var RawMimeTypes = [...]ImageType{ + TypeDNG, + TypeARW, + TypeSR2, + TypeSRF, + TypeCR2, + TypeCRW, + TypeERF, + TypeDCS, + TypeDRF, + TypeDCR, + TypeK25, + TypeKDC, + TypeMRW, + TypeMDC, + TypeNEF, + TypeNRW, + TypeORF, + TypePEF, + TypeRAF, + TypeRAW, + TypeRW2, + TypeGPR, + Type3FR, + TypeFFF, + TypeMEF, + TypeCap, + TypeIIQ, + TypeMOS, + TypeRWL, + TypeSRW, +} + +var fileExtensions = map[string]ImageType{ + ".jpg": TypeJpeg, + ".jpeg": TypeJpeg, + ".png": TypePng, + ".tif": TypeTiff, + ".tiff": TypeTiff, + ".bmp": TypeBmp, + + // RAW formats + ".dng": TypeDNG, + ".arw": TypeARW, + ".sr2": TypeSR2, + ".srf": TypeSRF, + ".cr2": TypeCR2, + ".crw": TypeCRW, + ".erf": TypeERF, + ".dcr": TypeDCR, + ".k25": TypeK25, + ".kdc": TypeKDC, + ".mrw": TypeMRW, + ".nef": TypeNEF, + ".nrw": TypeNRW, + ".orf": TypeORF, + ".pef": TypePEF, + ".raf": TypeRAF, + ".raw": TypeRAW, + ".dcs": TypeDCS, + ".drf": TypeDRF, + ".gpr": TypeGPR, + ".3fr": Type3FR, + ".fff": TypeFFF, +} + +func (imgType *ImageType) isRaw() bool { + for _, raw_mime := range RawMimeTypes { + if raw_mime == *imgType { + return true + } + } + + return false +} + +func (imgType *ImageType) isWebCompatible() bool { + for _, web_mime := range WebMimetypes { + if web_mime == *imgType { + return true + } + } + + return false +} + +func (imgType *ImageType) isSupported() bool { + for _, supported_mime := range SupportedMimetypes { + if supported_mime == *imgType { + return true + } + } + + return false +} + +func getImageType(path string) (*ImageType, error) { + + ext := filepath.Ext(path) + + fileExtType := fileExtensions[strings.ToLower(ext)] + + if fileExtType.isSupported() { + return &fileExtType, nil + } + + // If extension was not recognized try to read file header + file, err := os.Open(path) + if err != nil { + return nil, errors.Wrapf(err, "could not open file to determine content-type %s", path) + } + defer file.Close() + + head := make([]byte, 261) + if _, err := file.Read(head); err != nil { + if err == io.EOF { + return nil, nil + } + + return nil, errors.Wrapf(err, "could not read file to determine content-type: %s", path) + } + + _imgType, err := filetype.Image(head) + if err != nil { + return nil, nil + } + + imgType := ImageType(_imgType.MIME.Value) + if imgType.isSupported() { + return &imgType, nil + } + + return nil, nil +} + +func isPathImage(path string, cache *scanner_cache) bool { + if cache.get_photo_type(path) != nil { + return true + } + + imageType, err := getImageType(path) + if err != nil { + ScannerError("%s (%s)", err, path) + return false + } + + if imageType != nil { + // Make sure file isn't empty + fileStats, err := os.Stat(path) + if err != nil || fileStats.Size() == 0 { + return false + } + + cache.insert_photo_type(path, *imageType) + return true + } + + log.Printf("File is not a supported image %s\n", path) + return false +} diff --git a/api/scanner/process_photo.go b/api/scanner/process_photo.go index 29a4a466..a4fc6f94 100644 --- a/api/scanner/process_photo.go +++ b/api/scanner/process_photo.go @@ -3,16 +3,13 @@ package scanner import ( "database/sql" "fmt" - "image" - "image/jpeg" "log" "os" "path" "strconv" "strings" - "github.com/disintegration/imaging" - "github.com/h2non/filetype" + "github.com/pkg/errors" "github.com/viktorstrate/photoview/api/graphql/models" "github.com/viktorstrate/photoview/api/utils" @@ -23,8 +20,6 @@ import ( _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" - - cr2Decoder "github.com/nf/cr2" ) // Higher order function used to check if PhotoURL for a given PhotoPurpose exists @@ -52,110 +47,43 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error { log.Printf("Processing photo: %s\n", photo.Path) - imageData := ProcessImageData{ + imageData := EncodeImageData{ photo: photo, } - photoName := path.Base(photo.Path) - - photoBaseName := photoName[0 : len(photoName)-len(path.Ext(photoName))] - photoBaseExt := path.Ext(photoName) - - photoChecker, err := makePhotoURLChecker(tx, photo.PhotoID) + photoUrlFromDB, err := makePhotoURLChecker(tx, photo.PhotoID) if err != nil { return err } // original photo url - origURL, err := photoChecker(models.PhotoOriginal) + origURL, err := photoUrlFromDB(models.PhotoOriginal) if err != nil { return err } - if origURL == nil { - original_image_name := fmt.Sprintf("%s_%s", photoBaseName, utils.GenerateToken()) - original_image_name = strings.ReplaceAll(original_image_name, " ", "_") + photoBaseExt - - photoImage, err := imageData.PhotoImage(tx) - if err != nil { - return err - } - - contentType, err := imageData.ContentType() - if err != nil { - return err - } - - photoDimensions := photoImage.Bounds().Max - - _, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", photo.PhotoID, original_image_name, photoDimensions.X, photoDimensions.Y, models.PhotoOriginal, contentType) - if err != nil { - log.Printf("Could not insert original photo url: %d, %s\n", photo.PhotoID, photoName) - return err - } - } - // Thumbnail - thumbURL, err := photoChecker(models.PhotoThumbnail) + thumbURL, err := photoUrlFromDB(models.PhotoThumbnail) if err != nil { - return err + return errors.Wrap(err, "error processing thumbnail") } // Highres - highResURL, err := photoChecker(models.PhotoHighRes) + highResURL, err := photoUrlFromDB(models.PhotoHighRes) if err != nil { - return err + return errors.Wrap(err, "error processing highres") } // Make sure photo cache directory exists photoCachePath, err := makePhotoCacheDir(photo) if err != nil { - return err - } - - // Save thumbnail to cache - if thumbURL == nil { - thumbnail_name := fmt.Sprintf("thumbnail_%s_%s", photoName, utils.GenerateToken()) - thumbnail_name = strings.ReplaceAll(thumbnail_name, ".", "_") - thumbnail_name = strings.ReplaceAll(thumbnail_name, " ", "_") - thumbnail_name = thumbnail_name + ".jpg" - - thumbnailImage, err := imageData.ThumbnailImage(tx) - if err != nil { - return err - } - - err = encodeImageJPEG(path.Join(*photoCachePath, thumbnail_name), thumbnailImage, &jpeg.Options{Quality: 70}) - if err != nil { - log.Println("ERROR: creating high-res cached image") - return err - } - - thumbSize := thumbnailImage.Bounds().Max - _, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", photo.PhotoID, thumbnail_name, thumbSize.X, thumbSize.Y, models.PhotoThumbnail, "image/jpeg") - if err != nil { - return err - } - } else if thumbURL != nil { - thumbPath := path.Join(*photoCachePath, thumbURL.PhotoName) - - if _, err := os.Stat(thumbPath); os.IsNotExist(err) { - fmt.Printf("Thumbnail photo found in database but not in cache, re-encoding photo to cache: %s\n", thumbURL.PhotoName) - - thumbnailImage, err := imageData.ThumbnailImage(tx) - if err != nil { - return err - } - - err = encodeImageJPEG(thumbPath, thumbnailImage, &jpeg.Options{Quality: 70}) - if err != nil { - log.Println("ERROR: creating thumbnail cached image") - return err - } - } + return errors.Wrap(err, "cache directory error") } // Generate high res jpeg + var photoDimensions *PhotoDimensions + var baseImagePath string = photo.Path + if highResURL == nil { contentType, err := imageData.ContentType() @@ -163,53 +91,93 @@ func ProcessPhoto(tx *sql.Tx, photo *models.Photo) error { return err } - original_web_safe := false - for _, web_mime := range WebMimetypes { - if *contentType == web_mime { - original_web_safe = true - break - } - } - - if !original_web_safe { - highres_name := fmt.Sprintf("highres_%s_%s", photoName, utils.GenerateToken()) + if !contentType.isWebCompatible() { + highres_name := fmt.Sprintf("highres_%s_%s", path.Base(photo.Path), utils.GenerateToken()) highres_name = strings.ReplaceAll(highres_name, ".", "_") highres_name = strings.ReplaceAll(highres_name, " ", "_") highres_name = highres_name + ".jpg" - photoImage, err := imageData.PhotoImage(tx) + baseImagePath = path.Join(*photoCachePath, highres_name) + + err = imageData.EncodeHighRes(tx, baseImagePath) if err != nil { - return err + return errors.Wrap(err, "creating high-res cached image") } - err = encodeImageJPEG(path.Join(*photoCachePath, highres_name), photoImage, &jpeg.Options{Quality: 70}) + photoDimensions, err = GetPhotoDimensions(baseImagePath) if err != nil { - log.Println("ERROR: creating high-res cached image") return err } _, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", - photo.PhotoID, highres_name, photoImage.Bounds().Max.X, photoImage.Bounds().Max.Y, models.PhotoHighRes, "image/jpeg") + photo.PhotoID, highres_name, photoDimensions.Width, photoDimensions.Height, models.PhotoHighRes, "image/jpeg") if err != nil { - log.Printf("Could not insert highres photo url: %d, %s\n", photo.PhotoID, photoName) + log.Printf("Could not insert highres photo url: %d, %s\n", photo.PhotoID, path.Base(photo.Path)) return err } } - } else if highResURL != nil { - highResPath := path.Join(*photoCachePath, highResURL.PhotoName) + } else { + // Verify that highres photo still exists in cache + baseImagePath = path.Join(*photoCachePath, highResURL.PhotoName) - if _, err := os.Stat(highResPath); os.IsNotExist(err) { + if _, err := os.Stat(baseImagePath); os.IsNotExist(err) { fmt.Printf("High-res photo found in database but not in cache, re-encoding photo to cache: %s\n", highResURL.PhotoName) - photoImage, err := imageData.PhotoImage(tx) + err = imageData.EncodeHighRes(tx, baseImagePath) + if err != nil { + return errors.Wrap(err, "creating high-res cached image") + } + } + } + + // Save original photo to database + if origURL == nil { + // Make sure photo dimensions is set + if photoDimensions == nil { + photoDimensions, err = GetPhotoDimensions(baseImagePath) if err != nil { return err } + } - err = encodeImageJPEG(highResPath, photoImage, &jpeg.Options{Quality: 70}) + if err = saveOriginalPhotoToDB(tx, photo, imageData, photoDimensions); err != nil { + return errors.Wrap(err, "saving original photo to database") + } + } + + // Save thumbnail to cache + if thumbURL == nil { + thumbnail_name := fmt.Sprintf("thumbnail_%s_%s", path.Base(photo.Path), utils.GenerateToken()) + thumbnail_name = strings.ReplaceAll(thumbnail_name, ".", "_") + thumbnail_name = strings.ReplaceAll(thumbnail_name, " ", "_") + thumbnail_name = thumbnail_name + ".jpg" + + // thumbnailImage, err := imageData.ThumbnailImage(tx) + // if err != nil { + // return err + // } + + thumbOutputPath := path.Join(*photoCachePath, thumbnail_name) + + thumbSize, err := EncodeThumbnail(baseImagePath, thumbOutputPath) + if err != nil { + return errors.Wrap(err, "could not create thumbnail cached image") + } + + _, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", photo.PhotoID, thumbnail_name, thumbSize.Width, thumbSize.Height, models.PhotoThumbnail, "image/jpeg") + if err != nil { + return err + } + } else { + // Verify that thumbnail photo still exists in cache + thumbPath := path.Join(*photoCachePath, thumbURL.PhotoName) + + if _, err := os.Stat(thumbPath); os.IsNotExist(err) { + fmt.Printf("Thumbnail photo found in database but not in cache, re-encoding photo to cache: %s\n", thumbURL.PhotoName) + + _, err := EncodeThumbnail(baseImagePath, thumbPath) if err != nil { - log.Println("ERROR: creating high-res cached image") - return err + return errors.Wrap(err, "could not create thumbnail cached image") } } } @@ -248,157 +216,24 @@ func makePhotoCacheDir(photo *models.Photo) (*string, error) { return &photoCachePath, nil } -func encodeImageJPEG(photoPath string, photoImage image.Image, jpegOptions *jpeg.Options) error { - photo_file, err := os.Create(photoPath) +func saveOriginalPhotoToDB(tx *sql.Tx, photo *models.Photo, imageData EncodeImageData, photoDimensions *PhotoDimensions) error { + photoName := path.Base(photo.Path) + photoBaseName := photoName[0 : len(photoName)-len(path.Ext(photoName))] + photoBaseExt := path.Ext(photoName) + + original_image_name := fmt.Sprintf("%s_%s", photoBaseName, utils.GenerateToken()) + original_image_name = strings.ReplaceAll(original_image_name, " ", "_") + photoBaseExt + + contentType, err := imageData.ContentType() if err != nil { - log.Printf("ERROR: Could not create file: %s\n", photoPath) return err } - defer photo_file.Close() - err = jpeg.Encode(photo_file, photoImage, jpegOptions) + _, err = tx.Exec("INSERT INTO photo_url (photo_id, photo_name, width, height, purpose, content_type) VALUES (?, ?, ?, ?, ?, ?)", photo.PhotoID, original_image_name, photoDimensions.Width, photoDimensions.Height, models.PhotoOriginal, contentType) if err != nil { + log.Printf("Could not insert original photo url: %d, %s\n", photo.PhotoID, photoName) return err } return nil } - -// ProcessImageData is used to easily decode image data, with a cache so expensive operations are not repeated -type ProcessImageData struct { - photo *models.Photo - _photoImage image.Image - _thumbnailImage image.Image - _contentType *string -} - -// ContentType reads the image to determine its content type -func (img *ProcessImageData) ContentType() (*string, error) { - if img._contentType != nil { - return img._contentType, nil - } - - file, err := os.Open(img.photo.Path) - if err != nil { - ScannerError("Could not open file %s: %s\n", img.photo.Path, err) - return nil, err - } - defer file.Close() - - head := make([]byte, 261) - if _, err := file.Read(head); err != nil { - ScannerError("Could not read photo %s: %s\n", img.photo.Path, err) - return nil, err - } - - imgType, err := filetype.Image(head) - if err != nil { - return nil, err - } - - img._contentType = &imgType.MIME.Value - return img._contentType, nil -} - -// PhotoImage reads and decodes the image file and saves it in a cache so the photo in only decoded once -func (img *ProcessImageData) PhotoImage(tx *sql.Tx) (image.Image, error) { - if img._photoImage != nil { - return img._photoImage, nil - } - - photoFile, err := os.Open(img.photo.Path) - if err != nil { - return nil, err - } - defer photoFile.Close() - - var photoImg image.Image - contentType, err := img.ContentType() - if err != nil { - return nil, err - } - - if contentType != nil && *contentType == "image/x-canon-cr2" { - photoImg, err = cr2Decoder.Decode(photoFile) - if err != nil { - return nil, utils.HandleError("cr2 raw image decoding", err) - } - } else { - photoImg, _, err = image.Decode(photoFile) - if err != nil { - return nil, utils.HandleError("image decoding", err) - } - } - - // Get orientation from exif data - row := tx.QueryRow("SELECT photo_exif.orientation FROM photo JOIN photo_exif WHERE photo.exif_id = photo_exif.exif_id AND photo.photo_id = ?", img.photo.PhotoID) - var orientation *int - if err = row.Scan(&orientation); err != nil { - // If not found use default orientation (not rotate) - if err == sql.ErrNoRows { - orientation = nil - } else { - return nil, err - } - } - - if orientation == nil { - defaultOrientation := 0 - orientation = &defaultOrientation - } - - switch *orientation { - case 2: - photoImg = imaging.FlipH(photoImg) - break - case 3: - photoImg = imaging.Rotate180(photoImg) - break - case 4: - photoImg = imaging.FlipV(photoImg) - break - case 5: - photoImg = imaging.Transpose(photoImg) - break - case 6: - photoImg = imaging.Rotate270(photoImg) - break - case 7: - photoImg = imaging.Transverse(photoImg) - break - case 8: - photoImg = imaging.Rotate90(photoImg) - break - default: - break - } - - img._photoImage = photoImg - return img._photoImage, nil -} - -// ThumbnailImage downsizes the image and returns it -func (img *ProcessImageData) ThumbnailImage(tx *sql.Tx) (image.Image, error) { - photoImage, err := img.PhotoImage(tx) - if err != nil { - return nil, err - } - - dimensions := photoImage.Bounds().Max - aspect := float64(dimensions.X) / float64(dimensions.Y) - - var width, height int - - if aspect > 1 { - width = 1024 - height = int(1024 / aspect) - } else { - width = int(1024 * aspect) - height = 1024 - } - - thumbImage := imaging.Thumbnail(photoImage, width, height, imaging.NearestNeighbor) - img._thumbnailImage = thumbImage - - return img._thumbnailImage, nil -} diff --git a/ui/package-lock.json b/ui/package-lock.json index 693c4dce..45d38cce 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5563,25 +5563,25 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "resolved": "", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "resolved": "", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "resolved": "", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "resolved": "", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "optional": true, "requires": { @@ -5591,13 +5591,13 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "resolved": "", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "resolved": "", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "optional": true, "requires": { @@ -5613,25 +5613,25 @@ }, "code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "resolved": "", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "resolved": "", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "resolved": "", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": "", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "optional": true }, @@ -5646,19 +5646,19 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "resolved": "", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "resolved": "", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "resolved": "", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "optional": true }, @@ -5673,13 +5673,13 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": "", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "resolved": "", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "optional": true, "requires": { @@ -5709,13 +5709,13 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "resolved": "", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "resolved": "", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "optional": true, "requires": { @@ -5733,7 +5733,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "resolved": "", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "optional": true, "requires": { @@ -5749,13 +5749,13 @@ }, "ini": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "resolved": "", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "resolved": "", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "optional": true, "requires": { @@ -5764,13 +5764,13 @@ }, "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "resolved": "", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "resolved": "", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "optional": true, "requires": { @@ -5842,7 +5842,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": "", "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "optional": true, "requires": { @@ -5877,7 +5877,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "resolved": "", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "optional": true, "requires": { @@ -5889,19 +5889,19 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "resolved": "", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "resolved": "", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "optional": true }, "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "resolved": "", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "optional": true, "requires": { @@ -5910,19 +5910,19 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": "", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "resolved": "", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "optional": true, "requires": { @@ -5932,7 +5932,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "optional": true }, @@ -5944,7 +5944,7 @@ }, "rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "resolved": "", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "optional": true, "requires": { @@ -5964,7 +5964,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "optional": true, "requires": { @@ -5988,19 +5988,19 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "resolved": "", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": "", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "optional": true }, "sax": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "resolved": "", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "optional": true }, @@ -6012,19 +6012,19 @@ }, "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "resolved": "", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "resolved": "", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": "", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "optional": true, "requires": { @@ -6035,7 +6035,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "optional": true, "requires": { @@ -6044,7 +6044,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "optional": true, "requires": { @@ -6053,7 +6053,7 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "resolved": "", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "optional": true }, @@ -6074,13 +6074,13 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": "", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "resolved": "", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "optional": true, "requires": { @@ -6089,7 +6089,7 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "resolved": "", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "optional": true }, @@ -9797,9 +9797,9 @@ } }, "yargs": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.1.0.tgz", - "integrity": "sha512-T39FNN1b6hCW4SOIk1XyTOWxtXdcen0t+XYrysQmChzSipvhBO8Bj0nK1ozAasdk24dNWuMZvr4k24nz+8HHLg==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", + "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==", "dev": true, "requires": { "cliui": "^6.0.0", @@ -9812,13 +9812,13 @@ "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", - "yargs-parser": "^16.1.0" + "yargs-parser": "^18.1.1" } }, "yargs-parser": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-16.1.0.tgz", - "integrity": "sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg==", + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "dev": true, "requires": { "camelcase": "^5.0.0",