media

package
v0.35.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package media provides high-level image and video upload, processing, and serving on top of the storage package. It handles resizing, format conversion, thumbnail generation, and URL construction for both local filesystem and S3-compatible backends.

Index

Constants

View Source
const (
	KB int64 = 1024
	MB int64 = 1024 * 1024
	GB int64 = 1024 * 1024 * 1024
)

Size constants.

View Source
const (
	FormatWebP = "webp"
	FormatJPEG = "jpeg"
	FormatPNG  = "png"
)

Output format constants.

View Source
const (
	TypeImage = "image"
	TypeVideo = "video"
)

MediaType distinguishes images from videos.

Variables

View Source
var (
	ErrFileTooLarge   = errors.New("media: file too large")
	ErrUnknownType    = errors.New("media: unsupported file type")
	ErrVideoTooLong   = errors.New("media: video exceeds maximum duration")
	ErrFFmpegNotFound = errors.New("media: ffmpeg not found in PATH")
	// ErrInvalidID is returned by *FromReaderWithID methods when the
	// caller-supplied id is not a canonical UUID. The check is deliberately
	// strict: the id is interpolated into storage paths, so allowing free-form
	// strings opens path-traversal and collision footguns. Callers using a
	// non-UUID identifier scheme should generate a UUID for storage and
	// keep their own id as a separate column.
	ErrInvalidID = errors.New("media: invalid id (must be a canonical UUID)")
	// ErrIDExists is returned by *FromReaderWithID methods when storage
	// already holds bytes under the given id and overwrite=false. Pass
	// overwrite=true to clobber an existing prefix (e.g. retrying after a
	// failed partial write).
	ErrIDExists = errors.New("media: id already exists")
)

Sentinel errors.

View Source
var (
	SizesAvatar = []ImageSize{
		{Name: "thumb", Width: 64, Height: 64},
		{Name: "small", Width: 150, Height: 150},
		{Name: "medium", Width: 400, Height: 400},
	}

	SizesCard = []ImageSize{
		{Name: "thumb", Width: 64, Height: 64},
		{Name: "small", Width: 150, Height: 150},
		{Name: "medium", Width: 400, Height: 400},
		{Name: "large", Width: 800, Height: 800},
		{Name: "xlarge", Width: 1200, Height: 1200},
	}

	SizesIcon = []ImageSize{
		{Name: "small", Width: 100, Height: 100},
		{Name: "medium", Width: 200, Height: 200},
		{Name: "large", Width: 400, Height: 400},
	}

	SizeOriginal = []ImageSize{
		{Name: "original", Width: 0, Height: 0},
	}
)

Preset size sets.

Functions

func DetectType

func DetectType(fh *multipart.FileHeader) (mediaType string, mimeType string, err error)

DetectType sniffs the MIME type from the file header and returns the media type (TypeImage or TypeVideo), the MIME string, or ErrUnknownType.

Types

type ImageRef

type ImageRef struct {
	// contains filtered or unexported fields
}

ImageRef provides URL construction for a specific uploaded image. It is returned by GetMedia / GetMediaCtx on image stores.

func (ImageRef) Biggest

func (r ImageRef) Biggest() string

Biggest returns the URL for the largest configured size (by width).

func (ImageRef) Size

func (r ImageRef) Size(name string) string

Size returns the URL for the named size variant.

func (ImageRef) Smallest

func (r ImageRef) Smallest() string

Smallest returns the URL for the smallest configured size (by width).

func (ImageRef) Thumb

func (r ImageRef) Thumb() string

Thumb returns the URL for the "thumb" size. If no thumb size is configured, it returns the smallest available size.

type ImageSize

type ImageSize struct {
	Name   string
	Width  int
	Height int
}

ImageSize defines a named output dimension.

type ImageStore

type ImageStore struct {
	// contains filtered or unexported fields
}

ImageStore handles image upload, processing, serving, and deletion.

func NewLocalImageStore

func NewLocalImageStore(store *storage.LocalStorage, urlPrefix string, cfg ImageStoreConfig, opts ...Option) (*ImageStore, error)

NewLocalImageStore creates an ImageStore backed by local filesystem storage. urlPrefix is the URL path prefix used to serve files (e.g. "/uploads").

func NewS3ImageStore

func NewS3ImageStore(store *storage.S3Storage, cfg ImageStoreConfig, opts ...Option) (*ImageStore, error)

NewS3ImageStore creates an ImageStore backed by S3-compatible storage. If cfg.BaseURL is set, public URLs use it as the base. If cfg.SignedExpiry is set, GetMediaCtx returns pre-signed URLs.

func (*ImageStore) Delete

func (s *ImageStore) Delete(ctx context.Context, id string) error

Delete removes all size variants for the given media ID.

func (*ImageStore) GetMedia

func (s *ImageStore) GetMedia(id string) ImageRef

GetMedia returns an ImageRef for constructing public URLs. For local stores it uses the URL prefix; for S3 stores with BaseURL it uses that.

func (*ImageStore) GetMediaCtx

func (s *ImageStore) GetMediaCtx(ctx context.Context, id string) ImageRef

GetMediaCtx returns an ImageRef that may use signed URLs for S3 stores with SignedExpiry configured. For local stores it behaves identically to GetMedia.

func (*ImageStore) ServeHandler

func (s *ImageStore) ServeHandler() echo.HandlerFunc

ServeHandler returns an Echo handler that serves image files from the store. For local stores it serves from the filesystem. For S3 stores it proxies through the storage layer.

func (*ImageStore) SignedURL

func (s *ImageStore) SignedURL(ctx context.Context, path string, expiry time.Duration, opts ...storage.SignOption) (string, error)

SignedURL generates a pre-signed URL for a specific storage path with a custom expiry. Only works with S3-backed stores. Options such as storage.WithAttachment are forwarded to the underlying SignURL.

func (*ImageStore) Upload

Upload processes and stores an image from a multipart file header.

func (*ImageStore) UploadFromReader

func (s *ImageStore) UploadFromReader(ctx context.Context, r io.Reader, size int64) (*ImageUploadResult, error)

UploadFromReader processes and stores an image from an io.Reader. The id is generated internally as a fresh UUID. size is the upper-bound byte count from the caller's perspective; the actual buffer is checked against MaxSize after MIME detection.

func (*ImageStore) UploadFromReaderWithID

func (s *ImageStore) UploadFromReaderWithID(ctx context.Context, id string, r io.Reader, size int64, overwrite bool) (*ImageUploadResult, error)

UploadFromReaderWithID processes and stores an image, using the supplied id as the storage path component instead of generating one internally.

The id MUST be in the 36-char canonical UUID form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` — the value of `id` is interpolated into storage paths, so loose validation would open the door to traversal (`"../foo"`), key collisions, and silent overwrite of unrelated assets. Other forms accepted by uuid.Parse (hyphenless 32-char, urn:uuid:..., {...} braced) are deliberately rejected: they'd let the same logical UUID land at two different storage keys. The empty string is also rejected — the whole point of *WithID is "the caller supplies the id," so falling through to UUID generation would make the method name a lie. All bad inputs return ErrInvalidID.

If storage already holds bytes under the id (e.g. a previous attempt partially wrote variants and didn't clean up), the call returns ErrIDExists unless overwrite=true. Workers retrying a failed job typically want overwrite=true so the retry replaces any residue. ErrIDExists is a best-effort precheck: concurrent *WithID calls with the same id and overwrite=false can both pass the existence check and race on Save (last-write-wins on S3, clobber on local FS). Callers that need hard exclusion should serialize at the source-of-truth layer — typically a unique constraint on the DB row that owns the id.

On ErrFileTooLarge (size pre-check), ErrInvalidID, and ErrIDExists the reader r is NOT consumed — all three errors are detected before detectMIME runs, so the rejection path doesn't drain the payload. Callers relying on the upload to drain a network or pipe reader must discard or reuse r explicitly on these errors. (ErrFileTooLarge can also fire AFTER detectMIME, when the buffered byte count exceeds MaxSize; in that case r has been drained by the MIME sniff.)

On any error after the first variant has been written, the method makes a best-effort attempt to delete the variants it has already saved before returning. Cleanup failures are logged via the configured slog logger and do not mask the original error.

type ImageStoreConfig

type ImageStoreConfig struct {
	Category     string
	Sizes        []ImageSize
	Quality      int
	Format       string
	MaxSize      int64
	SignedExpiry time.Duration
	BaseURL      string
}

ImageStoreConfig configures an image store.

type ImageUploadResult

type ImageUploadResult struct {
	ID        string
	MediaType string
	MimeType  string
	// contains filtered or unexported fields
}

ImageUploadResult is returned after a successful image upload.

func (*ImageUploadResult) Path

func (r *ImageUploadResult) Path(size string) string

Path returns the storage path for the given size variant.

type Option

type Option func(*options)

Option configures a media store.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger for the store.

type VideoRef

type VideoRef struct {
	// contains filtered or unexported fields
}

VideoRef provides URL construction for a specific uploaded video. It is returned by GetMedia / GetMediaCtx on video stores.

func (VideoRef) Thumbnail

func (r VideoRef) Thumbnail() string

Thumbnail returns the URL for the video thumbnail. Returns empty string if thumbnail generation was not enabled.

func (VideoRef) Video

func (r VideoRef) Video() string

Video returns the URL for the video file.

type VideoStore

type VideoStore struct {
	// contains filtered or unexported fields
}

VideoStore handles video upload, processing, serving, and deletion.

func NewLocalVideoStore

func NewLocalVideoStore(store *storage.LocalStorage, urlPrefix string, cfg VideoStoreConfig, opts ...Option) (*VideoStore, error)

NewLocalVideoStore creates a VideoStore backed by local filesystem storage.

func NewS3VideoStore

func NewS3VideoStore(store *storage.S3Storage, cfg VideoStoreConfig, opts ...Option) (*VideoStore, error)

NewS3VideoStore creates a VideoStore backed by S3-compatible storage.

func (*VideoStore) Delete

func (s *VideoStore) Delete(ctx context.Context, id string) error

Delete removes the video and its thumbnail for the given media ID.

func (*VideoStore) GetMedia

func (s *VideoStore) GetMedia(id string) VideoRef

GetMedia returns a VideoRef for constructing public URLs.

func (*VideoStore) GetMediaCtx

func (s *VideoStore) GetMediaCtx(ctx context.Context, id string) VideoRef

GetMediaCtx returns a VideoRef that may use signed URLs for S3 stores.

func (*VideoStore) ServeHandler

func (s *VideoStore) ServeHandler() echo.HandlerFunc

ServeHandler returns an Echo handler that serves video files from the store.

func (*VideoStore) SignedURL

func (s *VideoStore) SignedURL(ctx context.Context, path string, expiry time.Duration, opts ...storage.SignOption) (string, error)

SignedURL generates a pre-signed URL for a specific storage path. Options such as storage.WithAttachment are forwarded to the underlying SignURL.

func (*VideoStore) Upload

Upload processes and stores a video from a multipart file header.

func (*VideoStore) UploadFromReader

func (s *VideoStore) UploadFromReader(ctx context.Context, r io.Reader, size int64) (*VideoUploadResult, error)

UploadFromReader processes and stores a video from an io.Reader. The id is generated internally as a fresh UUID.

func (*VideoStore) UploadFromReaderWithID

func (s *VideoStore) UploadFromReaderWithID(ctx context.Context, id string, r io.Reader, size int64, overwrite bool) (*VideoUploadResult, error)

UploadFromReaderWithID processes and stores a video using the supplied id as the storage path component instead of generating one internally.

The id MUST be in the 36-char canonical UUID form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`. Other forms accepted by uuid.Parse (hyphenless, urn, braced) and the empty string are rejected with ErrInvalidID — same contract as the image variant; see ImageStore.UploadFromReaderWithID for the rationale.

If storage already holds bytes under the id, the call returns ErrIDExists unless overwrite=true. Workers retrying a failed transcode typically want overwrite=true so the retry replaces any residue. ErrIDExists is a best-effort precheck — see ImageStore.UploadFromReaderWithID for the concurrent-callers caveat.

On ErrFileTooLarge (size pre-check), ErrInvalidID, and ErrIDExists the reader r is NOT consumed — all three errors are detected before detectMIME / ffprobe run, so the rejection path doesn't drain the payload. Callers relying on the upload to drain a network or pipe reader must discard or reuse r explicitly on these errors. (ErrFileTooLarge can also fire after the MIME sniff against the actual buffered byte count; in that case r has been drained.)

Thumbnail generation/save failures are logged and ignored — the video bytes are still stored, and result.ThumbnailPath stays empty when this branch fails. There is no partial-write window because the video Save is the only step that can fail after the id is fixed.

type VideoStoreConfig

type VideoStoreConfig struct {
	Category string
	// MaxSize is the maximum upload size in bytes. It's checked
	// against the input payload, before any transcode runs — we
	// reject oversized uploads at the door rather than after spending
	// CPU. The encoded output is *not* re-checked: a pathological
	// input (low-bitrate exotic codec) can in principle balloon
	// past MaxSize once re-encoded at CRF 23, so callers with hard
	// storage budgets should size MaxSize conservatively or pair it
	// with quota tracking at the storage layer.
	MaxSize           int64
	MaxDuration       float64
	GenerateThumbnail bool
	ThumbnailWidth    int
	BaseURL           string
	SignedExpiry      time.Duration
	Transcode         VideoTranscodeOptions
}

VideoStoreConfig configures a video store.

Transcode is opt-in via population: a zero-valued Transcode means uploads are stored verbatim (the original behaviour); any non-zero field flips the store into "transcode every upload to H.264/AAC MP4 before saving" mode. See VideoTranscodeOptions for the knobs and the defaults applied to fields the caller leaves unset.

type VideoTranscodeOptions

type VideoTranscodeOptions struct {
	// CRF is the H.264 constant-rate-factor. Effective range is 1–51;
	// lower = higher quality and larger files. 18–28 is the practical
	// band; the package default is 23 (the x264 baseline).
	//
	// CRF=0 is **not** lossless mode here even though libx264 accepts
	// it as such: 0 is the Go zero value and we use it as the "caller
	// did not set this" sentinel for the default-fill path, so passing
	// CRF=0 ends up as CRF=23 on the encoder. Lossless H.264 for web
	// video is vanishingly rare and produces huge files, and the clean
	// default-fill API is worth losing that one edge case. Callers who
	// genuinely need lossless transcoding should run ffmpeg themselves
	// and feed the result to UploadFromReader with Transcode left zero.
	CRF int

	// Preset is the x264 speed/compression tradeoff. One of: ultrafast,
	// superfast, veryfast, faster, fast, medium, slow, slower, veryslow,
	// placebo. Default: "medium".
	Preset string

	// AudioBitrate is passed verbatim to ffmpeg's -b:a flag (e.g.
	// "128k", "192k"). Default: "128k".
	AudioBitrate string

	// MaxWidth and MaxHeight clamp output dimensions while preserving
	// aspect ratio. Zero on either axis means "no clamp on this axis".
	// When both are zero the source dimensions are preserved.
	//
	// IMPORTANT: setting either of these on its own is enough to flip
	// IsZero() to false and activate the full transcode pass — there
	// is no "clamp size but otherwise leave bytes alone" mode. If you
	// only want size clamping, you still get an H.264/AAC re-encode
	// of every upload. Callers who pass through known-good MP4 should
	// either leave Transcode entirely zero, or accept the re-encode.
	MaxWidth, MaxHeight int
}

VideoTranscodeOptions configures the optional H.264/AAC MP4 transcode pass run by VideoStore.Upload* before bytes are persisted.

Activation is by population, not a flag: a zero-value struct means "do not transcode, save the upload bytes verbatim". As soon as any field is non-zero the transcode pass runs on every upload, and any fields left at their zero value are filled in with sensible defaults during store construction (see validate()).

The transcoder pins libx264 with `-pix_fmt yuv420p` and `-profile:v high` for broad browser/QuickTime/smart-TV compatibility (Safari and many TV decoders refuse 4:2:2 H.264, which iPhone 4K footage commonly produces); these aren't configurable on purpose. The output container is MP4 with `+faststart` so the moov atom lands at the front and the file streams progressively.

func (VideoTranscodeOptions) IsZero

func (o VideoTranscodeOptions) IsZero() bool

IsZero reports whether the options struct is in its zero state, i.e. the caller has not requested transcoding. Used by VideoStore.upload to decide between "save raw bytes" and "transcode then save".

type VideoUploadResult

type VideoUploadResult struct {
	ID            string
	MediaType     string
	MimeType      string
	Duration      float64
	FileSize      int64
	ThumbnailPath string
	// contains filtered or unexported fields
}

VideoUploadResult is returned after a successful video upload.

func (*VideoUploadResult) Path

func (r *VideoUploadResult) Path() string

Path returns the storage path for the video file.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL