Skip to content
This repository has been archived by the owner on Nov 25, 2024. It is now read-only.

Commit

Permalink
mediaapi: Add thumbnail support (#132)
Browse files Browse the repository at this point in the history
* vendor: Add bimg image processing library

bimg is MIT licensed. It depends on the C library libvips which is LGPL
v2.1+ licensed. libvips must be installed separately.

* mediaapi: Add YAML config file support

* mediaapi: Add thumbnail support

* mediaapi: Add missing thumbnail files

* travis: Add ppa and install libvips-dev

* travis: Another ppa and install libvips-dev attempt

* travis: Add sudo: required for sudo apt* usage

* mediaapi/thumbnailer: Make comparison code more readable

* mediaapi: Simplify logging of thumbnail properties

* mediaapi/thumbnailer: Rename metrics to fitness

Metrics is used in the context of monitoring with Prometheus so renaming
to avoid confusion.

* mediaapi/thumbnailer: Use math.Inf() for max aspect and size

* mediaapi/thumbnailer: Limit number of parallel generators

Fall back to selecting from already-/pre-generated thumbnails or serving
the original.

* mediaapi/thumbnailer: Split bimg code into separate file

* vendor: Add github.com/nfnt/resize pure go image scaler

* mediaapi/thumbnailer: Add nfnt/resize thumbnailer

* travis: Don't install libvips-dev via ppa

* mediaapi: Add notes to README about resizers

* mediaapi: Elaborate on scaling libs in README
  • Loading branch information
superdump authored Jun 6, 2017
1 parent def4940 commit 2d202ce
Show file tree
Hide file tree
Showing 73 changed files with 10,025 additions and 81 deletions.
38 changes: 38 additions & 0 deletions media-api-server-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# The name of the server. This is usually the domain name, e.g 'matrix.org', 'localhost'.
server_name: "example.com"

# The base path to where the media files will be stored. May be relative or absolute.
base_path: /var/dendrite/media

# The maximum file size in bytes that is allowed to be stored on this server.
# Note: if max_file_size_bytes is set to 0, the size is unlimited.
# Note: if max_file_size_bytes is not set, it will default to 10485760 (10MB)
max_file_size_bytes: 10485760

# The postgres connection config for connecting to the database e.g a postgres:// URI
database: "postgres://dendrite:itsasecret@localhost/mediaapi?sslmode=disable"

# Whether to dynamically generate thumbnails on-the-fly if the requested resolution is not already generated
# NOTE: This is a possible denial-of-service attack vector - use at your own risk
dynamic_thumbnails: false

# A list of thumbnail sizes to be pre-generated for downloaded remote / uploaded content
# method is one of crop or scale. If omitted, it will default to scale.
# crop scales to fill the requested dimensions and crops the excess.
# scale scales to fit the requested dimensions and one dimension may be smaller than requested.
thumbnail_sizes:
- width: 32
height: 32
method: crop
- width: 96
height: 96
method: crop
- width: 320
height: 240
method: scale
- width: 640
height: 480
method: scale
- width: 800
height: 600
method: scale
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
package main

import (
"fmt"
"io/ioutil"
"net/http"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"

"github.com/matrix-org/dendrite/common"
"github.com/matrix-org/dendrite/mediaapi/config"
Expand All @@ -28,6 +32,7 @@ import (
"github.com/matrix-org/gomatrixserverlib"

log "github.com/Sirupsen/logrus"
yaml "gopkg.in/yaml.v2"
)

var (
Expand All @@ -38,52 +43,209 @@ var (
basePath = os.Getenv("BASE_PATH")
// Note: if the MAX_FILE_SIZE_BYTES is set to 0, it will be unlimited
maxFileSizeBytesString = os.Getenv("MAX_FILE_SIZE_BYTES")
configPath = os.Getenv("CONFIG_PATH")
)

func main() {
common.SetupLogging(logDir)

if bindAddr == "" {
log.Panic("No BIND_ADDRESS environment variable found.")
log.WithFields(log.Fields{
"BIND_ADDRESS": bindAddr,
"DATABASE": dataSource,
"LOG_DIR": logDir,
"SERVER_NAME": serverName,
"BASE_PATH": basePath,
"MAX_FILE_SIZE_BYTES": maxFileSizeBytesString,
"CONFIG_PATH": configPath,
}).Info("Loading configuration based on config file and environment variables")

cfg, err := configureServer()
if err != nil {
log.WithError(err).Fatal("Invalid configuration")
}
if basePath == "" {
log.Panic("No BASE_PATH environment variable found.")

db, err := storage.Open(cfg.DataSource)
if err != nil {
log.WithError(err).Panic("Failed to open database")
}
absBasePath, err := filepath.Abs(basePath)

log.WithFields(log.Fields{
"BIND_ADDRESS": bindAddr,
"LOG_DIR": logDir,
"CONFIG_PATH": configPath,
"ServerName": cfg.ServerName,
"AbsBasePath": cfg.AbsBasePath,
"MaxFileSizeBytes": *cfg.MaxFileSizeBytes,
"DataSource": cfg.DataSource,
"DynamicThumbnails": cfg.DynamicThumbnails,
"MaxThumbnailGenerators": cfg.MaxThumbnailGenerators,
"ThumbnailSizes": cfg.ThumbnailSizes,
}).Info("Starting mediaapi server with configuration")

routing.Setup(http.DefaultServeMux, http.DefaultClient, cfg, db)
log.Fatal(http.ListenAndServe(bindAddr, nil))
}

// configureServer loads configuration from a yaml file and overrides with environment variables
func configureServer() (*config.MediaAPI, error) {
cfg, err := loadConfig(configPath)
if err != nil {
log.WithError(err).WithField("BASE_PATH", basePath).Panic("BASE_PATH is invalid (must be able to make absolute)")
log.WithError(err).Fatal("Invalid config file")
}

if serverName == "" {
serverName = "localhost"
// override values from environment variables
applyOverrides(cfg)

if err := validateConfig(cfg); err != nil {
return nil, err
}
maxFileSizeBytes, err := strconv.ParseInt(maxFileSizeBytesString, 10, 64)

return cfg, nil
}

// FIXME: make common somehow? copied from sync api
func loadConfig(configPath string) (*config.MediaAPI, error) {
contents, err := ioutil.ReadFile(configPath)
if err != nil {
maxFileSizeBytes = 10 * 1024 * 1024
log.WithError(err).WithField("MAX_FILE_SIZE_BYTES", maxFileSizeBytesString).Warnf("Failed to parse MAX_FILE_SIZE_BYTES. Defaulting to %v bytes.", maxFileSizeBytes)
return nil, err
}
var cfg config.MediaAPI
if err = yaml.Unmarshal(contents, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

cfg := &config.MediaAPI{
ServerName: gomatrixserverlib.ServerName(serverName),
AbsBasePath: types.Path(absBasePath),
MaxFileSizeBytes: types.FileSizeBytes(maxFileSizeBytes),
DataSource: dataSource,
func applyOverrides(cfg *config.MediaAPI) {
if serverName != "" {
if cfg.ServerName != "" {
log.WithFields(log.Fields{
"server_name": cfg.ServerName,
"SERVER_NAME": serverName,
}).Info("Overriding server_name from config file with environment variable")
}
cfg.ServerName = gomatrixserverlib.ServerName(serverName)
}
if cfg.ServerName == "" {
log.Info("ServerName not set. Defaulting to 'localhost'.")
cfg.ServerName = "localhost"
}

db, err := storage.Open(cfg.DataSource)
if basePath != "" {
if cfg.BasePath != "" {
log.WithFields(log.Fields{
"base_path": cfg.BasePath,
"BASE_PATH": basePath,
}).Info("Overriding base_path from config file with environment variable")
}
cfg.BasePath = types.Path(basePath)
}

if maxFileSizeBytesString != "" {
if cfg.MaxFileSizeBytes != nil {
log.WithFields(log.Fields{
"max_file_size_bytes": *cfg.MaxFileSizeBytes,
"MAX_FILE_SIZE_BYTES": maxFileSizeBytesString,
}).Info("Overriding max_file_size_bytes from config file with environment variable")
}
maxFileSizeBytesInt, err := strconv.ParseInt(maxFileSizeBytesString, 10, 64)
if err != nil {
maxFileSizeBytesInt = 10 * 1024 * 1024
log.WithError(err).WithField(
"MAX_FILE_SIZE_BYTES", maxFileSizeBytesString,
).Infof("MAX_FILE_SIZE_BYTES not set? Defaulting to %v bytes.", maxFileSizeBytesInt)
}
maxFileSizeBytes := types.FileSizeBytes(maxFileSizeBytesInt)
cfg.MaxFileSizeBytes = &maxFileSizeBytes
}

if dataSource != "" {
if cfg.DataSource != "" {
log.WithFields(log.Fields{
"database": cfg.DataSource,
"DATABASE": dataSource,
}).Info("Overriding database from config file with environment variable")
}
cfg.DataSource = dataSource
}

if cfg.MaxThumbnailGenerators == 0 {
log.WithField(
"max_thumbnail_generators", cfg.MaxThumbnailGenerators,
).Info("Using default max_thumbnail_generators")
cfg.MaxThumbnailGenerators = 10
}
}

func validateConfig(cfg *config.MediaAPI) error {
if bindAddr == "" {
return fmt.Errorf("no BIND_ADDRESS environment variable found")
}

absBasePath, err := getAbsolutePath(cfg.BasePath)
if err != nil {
log.WithError(err).Panic("Failed to open database")
return fmt.Errorf("invalid base path (%v): %q", cfg.BasePath, err)
}
cfg.AbsBasePath = types.Path(absBasePath)

log.WithFields(log.Fields{
"BASE_PATH": absBasePath,
"BIND_ADDRESS": bindAddr,
"DATABASE": dataSource,
"LOG_DIR": logDir,
"MAX_FILE_SIZE_BYTES": maxFileSizeBytes,
"SERVER_NAME": serverName,
}).Info("Starting mediaapi")
if *cfg.MaxFileSizeBytes < 0 {
return fmt.Errorf("invalid max file size bytes (%v)", *cfg.MaxFileSizeBytes)
}

routing.Setup(http.DefaultServeMux, http.DefaultClient, cfg, db)
log.Fatal(http.ListenAndServe(bindAddr, nil))
if cfg.DataSource == "" {
return fmt.Errorf("invalid database (%v)", cfg.DataSource)
}

for _, config := range cfg.ThumbnailSizes {
if config.Width <= 0 || config.Height <= 0 {
return fmt.Errorf("invalid thumbnail size %vx%v", config.Width, config.Height)
}
}

return nil
}

func getAbsolutePath(basePath types.Path) (types.Path, error) {
var err error
if basePath == "" {
var wd string
wd, err = os.Getwd()
return types.Path(wd), err
}
// Note: If we got here len(basePath) >= 1
if basePath[0] == '~' {
basePath, err = expandHomeDir(basePath)
if err != nil {
return "", err
}
}
absBasePath, err := filepath.Abs(string(basePath))
return types.Path(absBasePath), err
}

// expandHomeDir parses paths beginning with ~/path or ~user/path and replaces the home directory part
func expandHomeDir(basePath types.Path) (types.Path, error) {
slash := strings.Index(string(basePath), "/")
if slash == -1 {
// pretend the slash is after the path as none was found within the string
// simplifies code using slash below
slash = len(basePath)
}
var usr *user.User
var err error
if slash == 1 {
// basePath is ~ or ~/path
usr, err = user.Current()
if err != nil {
return "", fmt.Errorf("failed to get user's home directory: %q", err)
}
} else {
// slash > 1
// basePath is ~user or ~user/path
usr, err = user.Lookup(string(basePath[1:slash]))
if err != nil {
return "", fmt.Errorf("failed to get user's home directory: %q", err)
}
}
return types.Path(filepath.Join(usr.HomeDir, string(basePath[slash:]))), nil
}
27 changes: 27 additions & 0 deletions src/github.com/matrix-org/dendrite/mediaapi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Media API

This server is responsible for serving `/media` requests as per:

http://matrix.org/docs/spec/client_server/r0.2.0.html#id43

## Scaling libraries

### nfnt/resize (default)

Thumbnailing uses https://github.com/nfnt/resize by default which is a pure golang image scaling library relying on image codecs from the standard library. It is ISC-licensed.

It is multi-threaded and uses Lanczos3 so produces sharp images. Using Lanczos3 all the way makes it slower than some other approaches like bimg. (~845ms in total for pre-generating 32x32-crop, 96x96-crop, 320x240-scale, 640x480-scale and 800x600-scale from a given JPEG image on a given machine.)

See the sample below for image quality with nfnt/resize:

![](nfnt-96x96-crop.jpg)

### bimg (uses libvips C library)

Alternatively one can use `gb build -tags bimg` to use bimg from https://github.com/h2non/bimg (MIT-licensed) which uses libvips from https://github.com/jcupitt/libvips (LGPL v2.1+ -licensed). libvips is a C library and must be installed/built separately. See the github page for details. Also note that libvips in turn has dependencies with a selection of FOSS licenses.

bimg and libvips have significantly better performance than nfnt/resize but produce slightly less-sharp images. bimg uses a box filter for downscaling to within about 200% of the target scale and then uses Lanczos3 for the last bit. This is a much faster approach but comes at the expense of sharpness. (~295ms in total for pre-generating 32x32-crop, 96x96-crop, 320x240-scale, 640x480-scale and 800x600-scale from a given JPEG image on a given machine.)

See the sample below for image quality with bimg:

![](bimg-96x96-crop.jpg)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,20 @@ import (
type MediaAPI struct {
// The name of the server. This is usually the domain name, e.g 'matrix.org', 'localhost'.
ServerName gomatrixserverlib.ServerName `yaml:"server_name"`
// The base path to where the media files will be stored. May be relative or absolute.
BasePath types.Path `yaml:"base_path"`
// The absolute base path to where media files will be stored.
AbsBasePath types.Path `yaml:"abs_base_path"`
AbsBasePath types.Path `yaml:"-"`
// The maximum file size in bytes that is allowed to be stored on this server.
// Note: if MaxFileSizeBytes is set to 0, the size is unlimited.
// Note: if max_file_size_bytes is not set, it will default to 10485760 (10MB)
MaxFileSizeBytes types.FileSizeBytes `yaml:"max_file_size_bytes"`
MaxFileSizeBytes *types.FileSizeBytes `yaml:"max_file_size_bytes,omitempty"`
// The postgres connection config for connecting to the database e.g a postgres:// URI
DataSource string `yaml:"database"`
// Whether to dynamically generate thumbnails on-the-fly if the requested resolution is not already generated
DynamicThumbnails bool `yaml:"dynamic_thumbnails"`
// The maximum number of simultaneous thumbnail generators. default: 10
MaxThumbnailGenerators int `yaml:"max_thumbnail_generators"`
// A list of thumbnail sizes to be pre-generated for downloaded remote / uploaded content
ThumbnailSizes []types.ThumbnailSize `yaml:"thumbnail_sizes"`
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 2d202ce

Please sign in to comment.