[server][img] Generate previews at multiple resolutions

This commit is contained in:
Abhishek Shroff
2025-09-04 05:23:48 +05:30
parent 8a4eb979ed
commit 4aeee8e336
8 changed files with 169 additions and 86 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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") {

View File

@@ -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.*,

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}