mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-01-06 19:49:56 -06:00
[server][img] Generate previews at multiple resolutions
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.*,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
43
server/internal/img/libreoffice.go
Normal file
43
server/internal/img/libreoffice.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
server/internal/img/resize.go
Normal file
44
server/internal/img/resize.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user