diff --git a/server/internal/core/config.go b/server/internal/core/config.go index e9dd50c8..5295e0e4 100644 --- a/server/internal/core/config.go +++ b/server/internal/core/config.go @@ -8,7 +8,7 @@ import ( type Config struct { User UserConfig `koanf:"users"` Publinks PublinksConfig `koanf:"publinks"` - Imgr img.Config `koanf:"imgr"` + Img img.Config `koanf:"img"` } type PublinksConfig struct { diff --git a/server/internal/core/core.go b/server/internal/core/core.go index b5e33f98..104f03f3 100644 --- a/server/internal/core/core.go +++ b/server/internal/core/core.go @@ -34,8 +34,8 @@ func Initialize(db db.Handler, cfg Config) error { } else { publinksPasswordHasher = c } - if t, err := img.New(cfg.Imgr); err != nil { - return errors.New("failed to create thumber: " + err.Error()) + if t, err := img.New(cfg.Img); err != nil { + return errors.New("failed to create img manager: " + err.Error()) } else { imgr = t } diff --git a/server/internal/core/resource_create.go b/server/internal/core/resource_create.go index 58f443c1..c71abb89 100644 --- a/server/internal/core/resource_create.go +++ b/server/internal/core/resource_create.go @@ -67,7 +67,7 @@ func (f *FileSystem) CreateFileByPath(path string, requestedID, versionID uuid.U } else { return computeProps(dest, func(len int, hash hash.Hash, mimeType string) error { sum := hex.EncodeToString(hash.Sum(nil)) - var generated bool + var generated int8 if generated, err = imgr.Generate(backend.Path(versionID.String()), mimeType); err != nil { return err } @@ -114,7 +114,7 @@ func (f *FileSystem) CreateFileVersion(r Resource, versionID uuid.UUID) (io.Writ } else { return computeProps(dest, func(len int, hash hash.Hash, mimeType string) error { sum := hex.EncodeToString(hash.Sum(nil)) - var generated bool + var generated int8 if generated, err = imgr.Generate(backend.Path(versionID.String()), mimeType); err != nil { return err } @@ -374,9 +374,9 @@ func updateResourceModified(db db.TxHandler, id uuid.UUID) error { return err } -func insertResourceVersion(db db.TxHandler, id, versionID uuid.UUID, size int64, mimeType, sha256 string, thumb bool) error { - const q = `INSERT INTO resource_versions(id, resource_id, size, mime_type, sha256, storage, preview) - VALUES (@version_id, @resource_id, @size, @mime_type, @sha256, @storage, @preview)` +func insertResourceVersion(db db.TxHandler, id, versionID uuid.UUID, size int64, mimeType, sha256 string, imgres int8) error { + const q = `INSERT INTO resource_versions(id, resource_id, size, mime_type, sha256, storage, imgres) + VALUES (@version_id, @resource_id, @size, @mime_type, @sha256, @storage, @imgres)` args := pgx.NamedArgs{ "resource_id": id, @@ -385,7 +385,7 @@ func insertResourceVersion(db db.TxHandler, id, versionID uuid.UUID, size int64, "mime_type": mimeType, "sha256": sha256, "storage": storage.DefaultBackendName, - "preview": thumb, + "imgres": imgres, } _, err := db.Exec(q, args) if err != nil && strings.Contains(err.Error(), "resource_versions_pkey") { diff --git a/server/internal/core/resource_select_queries.go b/server/internal/core/resource_select_queries.go index 09821789..e1324b7c 100644 --- a/server/internal/core/resource_select_queries.go +++ b/server/internal/core/resource_select_queries.go @@ -25,7 +25,7 @@ const versionsQuery = "COALESCE(JSONB_AGG(JSONB_BUILD_OBJECT(" + "'mime_type',v.mime_type," + "'size',v.size," + "'sha256',v.sha256," + - "'preview',v.preview" + + "'imgres',v.imgres" + ") ORDER BY v.created DESC), '[]'::JSONB)" const fullResourceQuery = `SELECT r.*, diff --git a/server/internal/core/resource_versions.go b/server/internal/core/resource_versions.go index 26fe8e8f..a6f0ca58 100644 --- a/server/internal/core/resource_versions.go +++ b/server/internal/core/resource_versions.go @@ -18,7 +18,7 @@ func (f *FileSystem) GetVersion(r Resource, versionIDStr string) (Version, error } } - const q = `SELECT id, created, size, mime_type, sha256, storage, preview FROM resource_versions + const q = `SELECT id, created, size, mime_type, sha256, storage, imgres FROM resource_versions WHERE resource_id = $1::UUID AND id = $2::UUID AND DELETED IS NULL` @@ -39,7 +39,7 @@ AND DELETED IS NULL` } func (f *FileSystem) GetAllVersions(r Resource) ([]Version, error) { - const q = `SELECT id, created, deleted, size, mime_type, sha256, storage, preview FROM resource_versions + const q = `SELECT id, created, deleted, size, mime_type, sha256, storage, imgres FROM resource_versions WHERE resource_id = $1::UUID ORDER BY created DESC` if rows, err := f.db.Query(q, r.id); err != nil { diff --git a/server/internal/img/libreoffice.go b/server/internal/img/libreoffice.go new file mode 100644 index 00000000..4412251c --- /dev/null +++ b/server/internal/img/libreoffice.go @@ -0,0 +1,43 @@ +package img + +import ( + "errors" + "os/exec" +) + +func generateDocumentPreview(input, outdir string) error { + args := []string{ + "--headless", + "--convert-to", + "webp", + "--outdir", + outdir, + input, + } + + cmd := exec.Command("soffice", args...) + + if err := cmd.Run(); err != nil { + return errors.New("failed to generate LibreOffice Document preview: " + err.Error()) + } + return nil +} + +func generateTextPreview(input, outdir string) error { + args := []string{ + "--headless", + "--convert-to", + "webp", + "--infilter=\"Text (encoded):UTF8,LF,Noto Sans,en-US\"", // TOOO: change font? locale? + "--outdir", + outdir, + input, + } + + cmd := exec.Command("soffice", args...) + + if err := cmd.Run(); err != nil { + return errors.New("failed to generate LibreOffice Text preview: " + err.Error()) + } + return nil +} diff --git a/server/internal/img/preview.go b/server/internal/img/preview.go index 2925f0a9..9e63a45d 100644 --- a/server/internal/img/preview.go +++ b/server/internal/img/preview.go @@ -2,33 +2,22 @@ package img import ( "errors" - "fmt" "io" "os" - "os/exec" "path/filepath" - "slices" "strings" "codeberg.org/shroff/phylum/server/internal/storage" ) -type Res = int8 - -const ( - ResThumbnail Res = 1 - ResLarge Res = 2 -) +const maxImageSizeNoPreview = 1024 * 1024 type Imgr struct { cfg Config dir string + tmp string } -// Text - No preview +Thumbnail -// Image - Preview(vips) + Thumbnail -// Video - Preview(ffmpeg) + Thumbnail -// Document - Preview (libreoffice) + Thumbnail func New(cfg Config) (*Imgr, error) { g := &Imgr{ cfg: cfg, @@ -39,97 +28,104 @@ func New(cfg Config) (*Imgr, error) { } else { g.dir = filepath.Join(storage.Root, cfg.Dir) } - if err := os.MkdirAll(g.dir, 0o700); err != nil { - return nil, errors.New("failed to create thumbnail directory: " + err.Error()) + g.tmp = filepath.Join(g.dir, "tmp") + + if err := os.MkdirAll(g.tmp, 0o700); err != nil { + return nil, errors.New("failed to create img directory: " + err.Error()) } return g, nil } func (g *Imgr) Open(id string, res Res) (io.ReadCloser, error) { - return os.Open(filepath.Join(g.dir, id)) + return os.Open(filepath.Join(g.dir, id) + "-" + resSuffixes[res] + ".webp") } -func (g *Imgr) Generate(path, mimeType string) (bool, error) { - output := filepath.Join(g.dir, filepath.Base(path)) +func (g *Imgr) Generate(path, mimeType string) (int8, error) { if imageType, ok := strings.CutPrefix(mimeType, "image/"); ok { if _, ok := imageTypes[imageType]; ok { - return true, g.createImagePreview(path, output) + return g.processImage(path) } } + if ok := strings.HasPrefix(mimeType, "text/"); ok { - return true, g.generateTextPreview(path, output) + return g.processText(path) } if subType, ok := strings.CutPrefix(mimeType, "application/"); ok { if _, ok := applicationImageTypes[subType]; ok { - return true, g.createImagePreview(path, output) + return g.processImage(path) } if _, ok := applicationTextTypes[subType]; ok { - return true, g.generateTextPreview(path, output) + return g.processText(path) } if strings.HasSuffix("+json", subType) || strings.HasSuffix("+xml", subType) { - return true, g.generateTextPreview(path, output) + return g.processText(path) } if _, ok := applicationDocumentTypes[subType]; ok { - return true, g.generateDocumentPreview(path, output) + return g.processDocument(path) } } - return false, nil + return 0, nil } -func (g *Imgr) createImagePreview(input, output string) error { - outputWithFormat := output + ".webp" - outputWithFormatQuality := fmt.Sprintf("%s[Q=%d]", outputWithFormat, g.cfg.Quality) - cmd := exec.Command( - "vips", - "thumbnail", - input, - outputWithFormatQuality, - "480", - "--size", - "down", - ) - err := cmd.Run() +// processImage generates preview + thumbnail for images (and PDF). +// Preview is only generated if the image size is larger than 1M +func (g *Imgr) processImage(input string) (int8, error) { + var generated Res + stat, err := os.Stat(input) if err != nil { - return errors.New("failed to create image preview: " + err.Error()) + return 0, errors.New("failed to find input image: " + err.Error()) } - return os.Rename(outputWithFormat, output) + base := filepath.Base(input) + src := input + if stat.Size() > maxImageSizeNoPreview { + if o, err := resizeVips(src, g.dir, base, g.cfg.Quality, ResLarge); err != nil { + return 0, err + } else { + src = o + } + generated |= ResLarge + } + + if _, err := resizeVips(src, g.dir, base, g.cfg.Quality, ResThumbnail); err != nil { + return 0, err + } + generated |= ResThumbnail + return generated, nil } -func (g *Imgr) generateTextPreview(input, output string) error { - return g.generateLibreOfficePreview(input, output, true) +// Generate thumbnail only for text using. +// A temporary document preview must be generated and then resized down +func (g *Imgr) processText(input string) (int8, error) { + if err := generateTextPreview(input, g.tmp); err != nil { + return 0, err + } + // No need to keep the full generated preview since we'll render + // the text directly on the client using file contents + base := filepath.Base(input) + src := filepath.Join(g.tmp, base+".webp") + defer os.Remove(src) + + if _, err := resizeVips(src, g.dir, base, g.cfg.Quality, ResThumbnail); err != nil { + return 0, err + } + return ResThumbnail, nil } -func (g *Imgr) generateDocumentPreview(input, output string) error { - return g.generateLibreOfficePreview(input, output, false) -} - -func (g *Imgr) generateLibreOfficePreview(input, output string, text bool) error { - args := []string{ - "--headless", - "--convert-to", - "webp", - "--outdir", - g.dir, - input, - } - if text { - args = slices.Insert(args, 1, "--infilter=\"Text (encoded):UTF8,LF,Noto Sans,en-US\"") - } - - cmd := exec.Command("soffice", args...) - - if err := cmd.Run(); err != nil { - return errors.New("failed to generate LibreOffice preview: " + err.Error()) - } - - tempOutput := filepath.Join(g.dir, filepath.Base(input+".webp")) - src := tempOutput + ".orig" - if err := os.Rename(tempOutput, src); err != nil { - return errors.New("failed to rename LibreOffice preview: " + err.Error()) - } - if err := g.createImagePreview(src, output); err != nil { - return errors.New("failed to resize LibreOffice preview: " + err.Error()) - } - return os.Remove(src) +// Generate preview and thumbnail for office documents. +func (g *Imgr) processDocument(input string) (int8, error) { + if err := generateDocumentPreview(input, g.dir); err != nil { + return 0, err + } + base := filepath.Base(input) + out := filepath.Join(g.dir, base+".webp") + src := filepath.Join(g.dir, base+"-"+resSuffixes[ResLarge]+".webp") + if err := os.Rename(out, src); err != nil { + return 0, errors.New("failed to rename document preview: " + err.Error()) + } + + if _, err := resizeVips(src, g.dir, base, g.cfg.Quality, ResThumbnail); err != nil { + return 0, err + } + return ResLarge | ResThumbnail, nil } diff --git a/server/internal/img/resize.go b/server/internal/img/resize.go new file mode 100644 index 00000000..6a8e0a5a --- /dev/null +++ b/server/internal/img/resize.go @@ -0,0 +1,44 @@ +package img + +import ( + "errors" + "fmt" + "os/exec" + "path/filepath" + "strconv" +) + +type Res = int8 + +const ( + ResThumbnail Res = 1 + ResLarge Res = 2 +) + +var resSuffixes = map[Res]string{ + ResThumbnail: "t", + ResLarge: "l", +} + +var resolutions = map[Res]int{ + ResThumbnail: 240, + ResLarge: 1200, +} + +func resizeVips(input, outdir, basename string, quality uint8, size Res) (string, error) { + output := filepath.Join(outdir, basename+"-"+resSuffixes[size]+".webp") + cmd := exec.Command( + "vips", + "thumbnail", + input, + fmt.Sprintf("%s[Q=%d]", output, quality), + strconv.Itoa(resolutions[size]), + "--size", + "down", + ) + err := cmd.Run() + if err != nil { + return "", errors.New("failed to resize image using vips: " + err.Error()) + } + return output, nil +}