chore: 初始化 Cloudreve 源码仓库
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"path/filepath"
|
||||
//"github.com/nfnt/resize"
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const thumbTempFolder = "thumb"
|
||||
|
||||
// maxDecodePixels caps the decoded pixel count to prevent pixel-bomb DoS:
|
||||
// stdlib decoders allocate bytesPerPixel*W*H from the header before reading
|
||||
// pixel data, so a tiny file with huge dimensions can otherwise OOM-kill the
|
||||
// process. 64 MP covers any realistic camera output.
|
||||
const maxDecodePixels = 64 * 1000 * 1000
|
||||
|
||||
// BuiltinSupportedExts lists file extensions supported by the built-in
|
||||
// thumbnail generator. Extensions are lowercased and do not include the dot.
|
||||
var BuiltinSupportedExts = []string{"jpg", "jpeg", "png", "gif"}
|
||||
|
||||
// Thumb 缩略图
|
||||
type Thumb struct {
|
||||
src image.Image
|
||||
ext string
|
||||
}
|
||||
|
||||
// NewThumbFromFile 从文件数据获取新的Thumb对象,
|
||||
// 尝试通过文件名name解码图像
|
||||
func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) {
|
||||
// 无扩展名时
|
||||
if ext == "" {
|
||||
return nil, fmt.Errorf("unknown image format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
var hdr bytes.Buffer
|
||||
cfg, _, err := image.DecodeConfig(io.TeeReader(file, &hdr))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read image config: %w (%w)", err, ErrPassThrough)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||
return nil, fmt.Errorf("image dimensions too large (%dx%d): %w",
|
||||
cfg.Width, cfg.Height, ErrPassThrough)
|
||||
}
|
||||
file = io.MultiReader(&hdr, file)
|
||||
|
||||
var img image.Image
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
img, err = jpeg.Decode(file)
|
||||
case "gif":
|
||||
img, err = gif.Decode(file)
|
||||
case "png":
|
||||
img, err = png.Decode(file)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown image format %q: %w", ext, ErrPassThrough)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image: %w (%w)", err, ErrPassThrough)
|
||||
}
|
||||
|
||||
return &Thumb{
|
||||
src: img,
|
||||
ext: ext[1:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetThumb 生成给定最大尺寸的缩略图
|
||||
func (image *Thumb) GetThumb(width, height uint) {
|
||||
//image.src = resize.Thumbnail(width, height, image.src, resize.Lanczos3)
|
||||
image.src = Thumbnail(width, height, image.src)
|
||||
}
|
||||
|
||||
// GetSize 获取图像尺寸
|
||||
func (image *Thumb) GetSize() (int, int) {
|
||||
b := image.src.Bounds()
|
||||
return b.Max.X, b.Max.Y
|
||||
}
|
||||
|
||||
// Save 保存图像到给定路径
|
||||
func (image *Thumb) Save(w io.Writer, encodeSetting *setting.ThumbEncode) (err error) {
|
||||
switch encodeSetting.Format {
|
||||
case "png":
|
||||
err = png.Encode(w, image.src)
|
||||
default:
|
||||
err = jpeg.Encode(w, image.src, &jpeg.Options{Quality: encodeSetting.Quality})
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
// Thumbnail will downscale provided image to max width and height preserving
|
||||
// original aspect ratio and using the interpolation function interp.
|
||||
// It will return original image, without processing it, if original sizes
|
||||
// are already smaller than provided constraints.
|
||||
func Thumbnail(maxWidth, maxHeight uint, img image.Image) image.Image {
|
||||
origBounds := img.Bounds()
|
||||
origWidth := uint(origBounds.Dx())
|
||||
origHeight := uint(origBounds.Dy())
|
||||
newWidth, newHeight := origWidth, origHeight
|
||||
|
||||
// Return original image if it have same or smaller size as constraints
|
||||
if maxWidth >= origWidth && maxHeight >= origHeight {
|
||||
return img
|
||||
}
|
||||
|
||||
// Preserve aspect ratio
|
||||
if origWidth > maxWidth {
|
||||
newHeight = uint(origHeight * maxWidth / origWidth)
|
||||
if newHeight < 1 {
|
||||
newHeight = 1
|
||||
}
|
||||
newWidth = maxWidth
|
||||
}
|
||||
|
||||
if newHeight > maxHeight {
|
||||
newWidth = uint(newWidth * maxHeight / newHeight)
|
||||
if newWidth < 1 {
|
||||
newWidth = 1
|
||||
}
|
||||
newHeight = maxHeight
|
||||
}
|
||||
return Resize(newWidth, newHeight, img)
|
||||
}
|
||||
|
||||
func Resize(newWidth, newHeight uint, img image.Image) image.Image {
|
||||
// Set the expected size that you want:
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, int(newWidth), int(newHeight)))
|
||||
// Resize:
|
||||
draw.BiLinear.Scale(dst, dst.Rect, img, img.Bounds(), draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// CreateAvatar 创建头像
|
||||
func (image *Thumb) CreateAvatar(width int) {
|
||||
image.src = Resize(uint(width), uint(width), image.src)
|
||||
}
|
||||
|
||||
type Builtin struct {
|
||||
settings setting.Provider
|
||||
}
|
||||
|
||||
func NewBuiltinGenerator(settings setting.Provider) *Builtin {
|
||||
return &Builtin{
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
func (b Builtin) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if es.Entity().Size() > b.settings.BuiltinThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
img, err := NewThumbFromFile(es, ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w, h := b.settings.ThumbSize(ctx)
|
||||
img.GetThumb(uint(w), uint(h))
|
||||
tempPath := filepath.Join(
|
||||
util.DataPath(b.settings.TempPath(ctx)),
|
||||
thumbTempFolder,
|
||||
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
|
||||
)
|
||||
|
||||
thumbFile, err := util.CreatNestedFile(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer thumbFile.Close()
|
||||
if err := img.Save(thumbFile, b.settings.ThumbEncode(ctx)); err != nil {
|
||||
return &Result{Path: tempPath}, err
|
||||
}
|
||||
|
||||
return &Result{Path: tempPath}, nil
|
||||
}
|
||||
|
||||
func (b Builtin) Priority() int {
|
||||
return 300
|
||||
}
|
||||
|
||||
func (b Builtin) Enabled(ctx context.Context) bool {
|
||||
return b.settings.BuiltinThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
urlTimeout = time.Duration(1) * time.Hour
|
||||
)
|
||||
|
||||
func NewFfmpegGenerator(l logging.Logger, settings setting.Provider) *FfmpegGenerator {
|
||||
return &FfmpegGenerator{l: l, settings: settings}
|
||||
}
|
||||
|
||||
type FfmpegGenerator struct {
|
||||
l logging.Logger
|
||||
settings setting.Provider
|
||||
}
|
||||
|
||||
func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if !util.IsInExtensionListExt(f.settings.FFMpegThumbExts(ctx), ext) {
|
||||
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
if es.Entity().Size() > f.settings.FFMpegThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
tempOutputPath := filepath.Join(
|
||||
util.DataPath(f.settings.TempPath(ctx)),
|
||||
thumbTempFolder,
|
||||
fmt.Sprintf("thumb_%s.png", uuid.Must(uuid.NewV4()).String()),
|
||||
)
|
||||
|
||||
if err := util.CreatNestedFolder(filepath.Dir(tempOutputPath)); err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp folder: %w", err)
|
||||
}
|
||||
|
||||
input := ""
|
||||
expire := time.Now().Add(urlTimeout)
|
||||
if es.IsLocal() && !es.Entity().Encrypted() {
|
||||
input = es.LocalPath(ctx)
|
||||
} else {
|
||||
opts := []entitysource.EntitySourceOption{
|
||||
entitysource.WithContext(ctx),
|
||||
entitysource.WithExpire(&expire),
|
||||
}
|
||||
if !es.Entity().Encrypted() {
|
||||
opts = append(opts, entitysource.WithNoInternalProxy())
|
||||
}
|
||||
src, err := es.Url(ctx, opts...)
|
||||
if err != nil {
|
||||
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to get entity url: %w", err)
|
||||
}
|
||||
|
||||
input = src.Url
|
||||
}
|
||||
|
||||
// Invoke ffmpeg
|
||||
w, h := f.settings.ThumbSize(ctx)
|
||||
scaleOpt := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", w, h)
|
||||
args := []string{
|
||||
"-ss", f.settings.FFMpegThumbSeek(ctx),
|
||||
}
|
||||
|
||||
extraArgs := f.settings.FFMpegExtraArgs(ctx)
|
||||
if extraArgs != "" {
|
||||
args = append(args, strings.Split(extraArgs, " ")...)
|
||||
}
|
||||
|
||||
args = append(args, []string{
|
||||
"-i", input,
|
||||
"-vf", scaleOpt,
|
||||
"-vframes", "1",
|
||||
tempOutputPath,
|
||||
}...)
|
||||
cmd := exec.CommandContext(ctx, f.settings.FFMpegPath(ctx), args...)
|
||||
|
||||
// Redirect IO
|
||||
var stdErr bytes.Buffer
|
||||
cmd.Stderr = &stdErr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
f.l.Warning("Failed to invoke ffmpeg: %s", stdErr.String())
|
||||
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s", err, stdErr.String())
|
||||
}
|
||||
|
||||
return &Result{Path: tempOutputPath}, nil
|
||||
}
|
||||
|
||||
func (f *FfmpegGenerator) Priority() int {
|
||||
return 200
|
||||
}
|
||||
|
||||
func (f *FfmpegGenerator) Enabled(ctx context.Context) bool {
|
||||
return f.settings.FFMpegThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
func NewLibRawGenerator(l logging.Logger, settings setting.Provider) *LibRawGenerator {
|
||||
return &LibRawGenerator{l: l, settings: settings}
|
||||
}
|
||||
|
||||
type LibRawGenerator struct {
|
||||
l logging.Logger
|
||||
settings setting.Provider
|
||||
}
|
||||
|
||||
func (l *LibRawGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if !util.IsInExtensionListExt(l.settings.LibRawThumbExts(ctx), ext) {
|
||||
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
if es.Entity().Size() > l.settings.LibRawThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
// If download/copy files to temp folder
|
||||
tempFolder := filepath.Join(
|
||||
util.DataPath(l.settings.TempPath(ctx)),
|
||||
"thumb",
|
||||
fmt.Sprintf("libraw_%s", uuid.Must(uuid.NewV4()).String()),
|
||||
)
|
||||
tempInputFileName := fmt.Sprintf("libraw_%s.%s", uuid.Must(uuid.NewV4()).String(), ext)
|
||||
tempPath := filepath.Join(tempFolder, tempInputFileName)
|
||||
tempInputFile, err := util.CreatNestedFile(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tempPath)
|
||||
defer tempInputFile.Close()
|
||||
|
||||
if _, err = io.Copy(tempInputFile, es); err != nil {
|
||||
return &Result{Path: tempPath}, fmt.Errorf("failed to write input file: %w", err)
|
||||
}
|
||||
|
||||
tempInputFile.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx,
|
||||
l.settings.LibRawThumbPath(ctx), "-e", tempPath)
|
||||
|
||||
// Redirect IO
|
||||
var dcrawErr bytes.Buffer
|
||||
cmd.Stderr = &dcrawErr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
l.l.Warning("Failed to invoke dcraw: %s", dcrawErr.String())
|
||||
return &Result{Path: tempPath}, fmt.Errorf("failed to invoke dcraw: %w, raw output: %s", err, dcrawErr.String())
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Path: filepath.Join(
|
||||
tempFolder,
|
||||
tempInputFileName+".thumb.jpg",
|
||||
),
|
||||
Continue: true,
|
||||
Cleanup: []func(){func() { _ = os.RemoveAll(tempFolder) }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *LibRawGenerator) Priority() int {
|
||||
return 50
|
||||
}
|
||||
|
||||
func (l *LibRawGenerator) Enabled(ctx context.Context) bool {
|
||||
return l.settings.LibRawThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
|
||||
func rotateImg(filePath string, orientation int) error {
|
||||
resultImg, err := os.OpenFile(filePath, os.O_RDWR, 0777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resultImg.Close() }()
|
||||
|
||||
imgFlag := make([]byte, 3)
|
||||
if _, err = io.ReadFull(resultImg, imgFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = resultImg.Seek(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var img image.Image
|
||||
if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
img, err = jpeg.Decode(resultImg)
|
||||
} else {
|
||||
img, err = png.Decode(resultImg)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch orientation {
|
||||
case 8:
|
||||
img = rotate90(img)
|
||||
case 3:
|
||||
img = rotate90(rotate90(img))
|
||||
case 6:
|
||||
img = rotate90(rotate90(rotate90(img)))
|
||||
case 2:
|
||||
img = mirrorImg(img)
|
||||
case 7:
|
||||
img = rotate90(mirrorImg(img))
|
||||
case 4:
|
||||
img = rotate90(rotate90(mirrorImg(img)))
|
||||
case 5:
|
||||
img = rotate90(rotate90(rotate90(mirrorImg(img))))
|
||||
}
|
||||
|
||||
if err = resultImg.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = resultImg.Seek(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes.Equal(imgFlag, []byte{0xFF, 0xD8, 0xFF}) {
|
||||
return jpeg.Encode(resultImg, img, nil)
|
||||
}
|
||||
return png.Encode(resultImg, img)
|
||||
}
|
||||
|
||||
func getJpegOrientation(fileName string) (int, error) {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
header := make([]byte, 6)
|
||||
defer func() { header = nil }()
|
||||
if _, err = io.ReadFull(f, header); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// jpeg format header
|
||||
if !bytes.Equal(header[:3], []byte{0xFF, 0xD8, 0xFF}) {
|
||||
return 0, errors.New("not a jpeg")
|
||||
}
|
||||
|
||||
// not a APP1 marker
|
||||
if header[3] != 0xE1 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// exif data total length
|
||||
totalLen := int(header[4])<<8 + int(header[5]) - 2
|
||||
buf := make([]byte, totalLen)
|
||||
defer func() { buf = nil }()
|
||||
if _, err = io.ReadFull(f, buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// remove Exif identifier code
|
||||
buf = buf[6:]
|
||||
|
||||
// byte order
|
||||
parse16, parse32, err := initParseMethod(buf[:2])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// version
|
||||
_ = buf[2:4]
|
||||
|
||||
// first IFD offset
|
||||
offset := parse32(buf[4:8])
|
||||
|
||||
// first DE offset
|
||||
offset += 2
|
||||
buf = buf[offset:]
|
||||
|
||||
const (
|
||||
orientationTag = 0x112
|
||||
deEntryLength = 12
|
||||
)
|
||||
for len(buf) > deEntryLength {
|
||||
tag := parse16(buf[:2])
|
||||
if tag == orientationTag {
|
||||
return int(parse32(buf[8:12])), nil
|
||||
}
|
||||
buf = buf[deEntryLength:]
|
||||
}
|
||||
|
||||
return 0, errors.New("orientation not found")
|
||||
}
|
||||
|
||||
func initParseMethod(buf []byte) (func([]byte) int16, func([]byte) int32, error) {
|
||||
if bytes.Equal(buf, []byte{0x49, 0x49}) {
|
||||
return littleEndian16, littleEndian32, nil
|
||||
}
|
||||
if bytes.Equal(buf, []byte{0x4D, 0x4D}) {
|
||||
return bigEndian16, bigEndian32, nil
|
||||
}
|
||||
return nil, nil, errors.New("invalid byte order")
|
||||
}
|
||||
|
||||
func littleEndian16(buf []byte) int16 {
|
||||
return int16(buf[0]) | int16(buf[1])<<8
|
||||
}
|
||||
|
||||
func bigEndian16(buf []byte) int16 {
|
||||
return int16(buf[1]) | int16(buf[0])<<8
|
||||
}
|
||||
|
||||
func littleEndian32(buf []byte) int32 {
|
||||
return int32(buf[0]) | int32(buf[1])<<8 | int32(buf[2])<<16 | int32(buf[3])<<24
|
||||
}
|
||||
|
||||
func bigEndian32(buf []byte) int32 {
|
||||
return int32(buf[3]) | int32(buf[2])<<8 | int32(buf[1])<<16 | int32(buf[0])<<24
|
||||
}
|
||||
|
||||
func rotate90(img image.Image) image.Image {
|
||||
bounds := img.Bounds()
|
||||
width, height := bounds.Dx(), bounds.Dy()
|
||||
newImg := image.NewRGBA(image.Rect(0, 0, height, width))
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
newImg.Set(y, width-x-1, img.At(x, y))
|
||||
}
|
||||
}
|
||||
return newImg
|
||||
}
|
||||
|
||||
func mirrorImg(img image.Image) image.Image {
|
||||
bounds := img.Bounds()
|
||||
width, height := bounds.Dx(), bounds.Dy()
|
||||
newImg := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for x := 0; x < width; x++ {
|
||||
for y := 0; y < height; y++ {
|
||||
newImg.Set(width-x-1, y, img.At(x, y))
|
||||
}
|
||||
}
|
||||
return newImg
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
func NewLibreOfficeGenerator(l logging.Logger, settings setting.Provider) *LibreOfficeGenerator {
|
||||
return &LibreOfficeGenerator{l: l, settings: settings}
|
||||
}
|
||||
|
||||
type LibreOfficeGenerator struct {
|
||||
settings setting.Provider
|
||||
l logging.Logger
|
||||
}
|
||||
|
||||
func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if !util.IsInExtensionListExt(l.settings.LibreOfficeThumbExts(ctx), ext) {
|
||||
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
if es.Entity().Size() > l.settings.LibreOfficeThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
tempOutputPath := filepath.Join(
|
||||
util.DataPath(l.settings.TempPath(ctx)),
|
||||
thumbTempFolder,
|
||||
fmt.Sprintf("soffice_%s", uuid.Must(uuid.NewV4()).String()),
|
||||
)
|
||||
|
||||
tempInputPath := ""
|
||||
if es.IsLocal() && !es.Entity().Encrypted() {
|
||||
tempInputPath = es.LocalPath(ctx)
|
||||
} else {
|
||||
// If not local policy files, download to temp folder
|
||||
tempInputPath = filepath.Join(
|
||||
util.DataPath(l.settings.TempPath(ctx)),
|
||||
"thumb",
|
||||
fmt.Sprintf("soffice_%s.%s", uuid.Must(uuid.NewV4()).String(), ext),
|
||||
)
|
||||
|
||||
// Due to limitations of ffmpeg, we need to write the input file to disk first
|
||||
tempInputFile, err := util.CreatNestedFile(tempInputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tempInputPath)
|
||||
defer tempInputFile.Close()
|
||||
|
||||
if _, err = io.Copy(tempInputFile, es); err != nil {
|
||||
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to write input file: %w", err)
|
||||
}
|
||||
|
||||
tempInputFile.Close()
|
||||
}
|
||||
|
||||
// Convert the document to an image
|
||||
cmd := exec.CommandContext(ctx, l.settings.LibreOfficePath(ctx), "--headless",
|
||||
"--nologo", "--nofirststartwizard", "--invisible", "--norestore", "--convert-to",
|
||||
"png", "--outdir", tempOutputPath, tempInputPath)
|
||||
|
||||
// Redirect IO
|
||||
var stdErr bytes.Buffer
|
||||
cmd.Stderr = &stdErr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
l.l.Warning("Failed to invoke LibreOffice: %s", stdErr.String())
|
||||
return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s", err, stdErr.String())
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Path: filepath.Join(
|
||||
tempOutputPath,
|
||||
strings.TrimSuffix(filepath.Base(tempInputPath), filepath.Ext(tempInputPath))+".png",
|
||||
),
|
||||
Continue: true,
|
||||
Cleanup: []func(){func() { _ = os.RemoveAll(tempOutputPath) }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *LibreOfficeGenerator) Priority() int {
|
||||
return 50
|
||||
}
|
||||
|
||||
func (l *LibreOfficeGenerator) Enabled(ctx context.Context) bool {
|
||||
return l.settings.LibreOfficeThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gofrs/uuid"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func NewMusicCoverGenerator(l logging.Logger, settings setting.Provider) *MusicCoverGenerator {
|
||||
return &MusicCoverGenerator{l: l, settings: settings}
|
||||
}
|
||||
|
||||
type MusicCoverGenerator struct {
|
||||
l logging.Logger
|
||||
settings setting.Provider
|
||||
}
|
||||
|
||||
func (v *MusicCoverGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if !util.IsInExtensionListExt(v.settings.MusicCoverThumbExts(ctx), ext) {
|
||||
return nil, fmt.Errorf("unsupported music format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
if es.Entity().Size() > v.settings.MusicCoverThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
m, err := tag.ReadFrom(es)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("faield to read audio tags from file: %w", err)
|
||||
}
|
||||
|
||||
p := m.Picture()
|
||||
if p == nil || len(p.Data) == 0 {
|
||||
return nil, fmt.Errorf("no cover found in given file")
|
||||
}
|
||||
|
||||
thumbExt := ".jpg"
|
||||
if p.Ext != "" {
|
||||
thumbExt = p.Ext
|
||||
}
|
||||
|
||||
tempPath := filepath.Join(
|
||||
util.DataPath(v.settings.TempPath(ctx)),
|
||||
thumbTempFolder,
|
||||
fmt.Sprintf("thumb_%s.%s", uuid.Must(uuid.NewV4()).String(), thumbExt),
|
||||
)
|
||||
|
||||
thumbFile, err := util.CreatNestedFile(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer thumbFile.Close()
|
||||
|
||||
if _, err := thumbFile.Write(p.Data); err != nil {
|
||||
return &Result{Path: tempPath}, fmt.Errorf("failed to write cover to file: %w", err)
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Path: tempPath,
|
||||
Continue: true,
|
||||
Cleanup: []func(){func() { _ = os.Remove(tempPath) }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *MusicCoverGenerator) Priority() int {
|
||||
return 50
|
||||
}
|
||||
|
||||
func (v *MusicCoverGenerator) Enabled(ctx context.Context) bool {
|
||||
return v.settings.MusicCoverThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
)
|
||||
|
||||
type (
|
||||
// Generator generates a thumbnail for a given reader.
|
||||
Generator interface {
|
||||
// Generate generates a thumbnail for a given reader. Src is the original file path, only provided
|
||||
// for local policy files. State is the result from previous generators, and can be read by current
|
||||
// generator for intermedia result.
|
||||
Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error)
|
||||
|
||||
// Priority of execution order, smaller value means higher priority.
|
||||
Priority() int
|
||||
|
||||
// Enabled returns if current generator is enabled.
|
||||
Enabled(ctx context.Context) bool
|
||||
}
|
||||
Result struct {
|
||||
Path string
|
||||
Ext string
|
||||
Continue bool
|
||||
Cleanup []func()
|
||||
}
|
||||
GeneratorType string
|
||||
|
||||
generatorList []Generator
|
||||
pipeline struct {
|
||||
generators generatorList
|
||||
settings setting.Provider
|
||||
l logging.Logger
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPassThrough = errors.New("pass through")
|
||||
ErrNotAvailable = fmt.Errorf("thumbnail not available: %w", ErrPassThrough)
|
||||
)
|
||||
|
||||
func (g generatorList) Len() int {
|
||||
return len(g)
|
||||
}
|
||||
|
||||
func (g generatorList) Less(i, j int) bool {
|
||||
return g[i].Priority() < g[j].Priority()
|
||||
}
|
||||
|
||||
func (g generatorList) Swap(i, j int) {
|
||||
g[i], g[j] = g[j], g[i]
|
||||
}
|
||||
|
||||
// NewPipeline creates a new pipeline with all available generators.
|
||||
func NewPipeline(settings setting.Provider, l logging.Logger) Generator {
|
||||
generators := generatorList{}
|
||||
generators = append(
|
||||
generators,
|
||||
NewBuiltinGenerator(settings),
|
||||
NewFfmpegGenerator(l, settings),
|
||||
NewVipsGenerator(l, settings),
|
||||
NewLibreOfficeGenerator(l, settings),
|
||||
NewMusicCoverGenerator(l, settings),
|
||||
NewLibRawGenerator(l, settings),
|
||||
)
|
||||
sort.Sort(generators)
|
||||
|
||||
return pipeline{
|
||||
generators: generators,
|
||||
settings: settings,
|
||||
l: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (p pipeline) Generate(ctx context.Context, es entitysource.EntitySource, ext string, state *Result) (*Result, error) {
|
||||
e := es.Entity()
|
||||
for _, generator := range p.generators {
|
||||
if generator.Enabled(ctx) {
|
||||
if _, err := es.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, fmt.Errorf("thumb: failed to seek to start of file: %w", err)
|
||||
}
|
||||
|
||||
res, err := generator.Generate(ctx, es, ext, state)
|
||||
if errors.Is(err, ErrPassThrough) {
|
||||
p.l.Debug("Failed to generate thumbnail using %s for %s: %s, passing through to next generator.", reflect.TypeOf(generator).String(), e.Source(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if res != nil && res.Continue {
|
||||
p.l.Debug("Generator %s for %s returned continue, passing through to next generator.", reflect.TypeOf(generator).String(), e.Source())
|
||||
|
||||
// defer cleanup functions
|
||||
for _, cleanup := range res.Cleanup {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
// prepare file reader for next generator
|
||||
state = res
|
||||
es, err = es.CloneToLocalSrc(types.EntityTypeVersion, res.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("thumb: failed to clone to local source: %w", err)
|
||||
}
|
||||
|
||||
defer es.Close()
|
||||
ext = util.Ext(res.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
|
||||
func (p pipeline) Priority() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (p pipeline) Enabled(ctx context.Context) bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownGenerator = errors.New("unknown generator type")
|
||||
ErrUnknownOutput = errors.New("unknown output from generator")
|
||||
)
|
||||
|
||||
// TestGenerator tests thumb generator by getting lib version
|
||||
func TestGenerator(ctx context.Context, name, executable string) (string, error) {
|
||||
switch name {
|
||||
case "vips":
|
||||
return testVipsGenerator(ctx, executable)
|
||||
case "ffmpeg":
|
||||
return testFfmpegGenerator(ctx, executable)
|
||||
case "libreOffice":
|
||||
return testLibreOfficeGenerator(ctx, executable)
|
||||
case "ffprobe":
|
||||
return testFFProbeGenerator(ctx, executable)
|
||||
case "libraw":
|
||||
return testLibRawGenerator(ctx, executable)
|
||||
default:
|
||||
return "", ErrUnknownGenerator
|
||||
}
|
||||
}
|
||||
|
||||
func testFFProbeGenerator(ctx context.Context, executable string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, executable, "-version")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to invoke ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), "ffprobe") {
|
||||
return "", ErrUnknownOutput
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func testVipsGenerator(ctx context.Context, executable string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, executable, "--version")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to invoke vips executable: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), "vips") {
|
||||
return "", ErrUnknownOutput
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func testFfmpegGenerator(ctx context.Context, executable string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, executable, "-version")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to invoke ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), "ffmpeg") {
|
||||
return "", ErrUnknownOutput
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func testLibreOfficeGenerator(ctx context.Context, executable string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, executable, "--version")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to invoke libreoffice executable: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), "LibreOffice") {
|
||||
return "", ErrUnknownOutput
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func testLibRawGenerator(ctx context.Context, executable string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, executable, "-L")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to invoke libraw executable: %w", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output.String(), "Sony") {
|
||||
return "", ErrUnknownOutput
|
||||
}
|
||||
|
||||
cameraList := strings.Split(output.String(), "\n")
|
||||
|
||||
return fmt.Sprintf("N/A, %d cameras supported", len(cameraList)), nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package thumb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
func NewVipsGenerator(l logging.Logger, settings setting.Provider) *VipsGenerator {
|
||||
return &VipsGenerator{l: l, settings: settings}
|
||||
}
|
||||
|
||||
type VipsGenerator struct {
|
||||
l logging.Logger
|
||||
settings setting.Provider
|
||||
}
|
||||
|
||||
func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
|
||||
if !util.IsInExtensionListExt(v.settings.VipsThumbExts(ctx), ext) {
|
||||
return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
if es.Entity().Size() > v.settings.VipsThumbMaxSize(ctx) {
|
||||
return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
|
||||
}
|
||||
|
||||
outputOpt := ".png"
|
||||
encode := v.settings.ThumbEncode(ctx)
|
||||
if encode.Format == "jpg" || encode.Format == "webp" {
|
||||
outputOpt = fmt.Sprintf(".%s[Q=%d]", encode.Format, encode.Quality)
|
||||
}
|
||||
|
||||
input := "[descriptor=0]"
|
||||
usePipe := true
|
||||
if runtime.GOOS == "windows" {
|
||||
// Pipe IO is not working on Windows for VIPS
|
||||
if es.IsLocal() && !es.Entity().Encrypted() {
|
||||
// escape [ and ] in file name
|
||||
input = fmt.Sprintf("[filename=\"%s\"]", es.LocalPath(ctx))
|
||||
usePipe = false
|
||||
} else {
|
||||
usePipe = false
|
||||
// If not local policy files, download to temp folder
|
||||
tempPath := filepath.Join(
|
||||
util.DataPath(v.settings.TempPath(ctx)),
|
||||
"thumb",
|
||||
fmt.Sprintf("vips_%s.%s", uuid.Must(uuid.NewV4()).String(), ext),
|
||||
)
|
||||
input = fmt.Sprintf("[filename=\"%s\"]", tempPath)
|
||||
|
||||
// Due to limitations of ffmpeg, we need to write the input file to disk first
|
||||
tempInputFile, err := util.CreatNestedFile(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer os.Remove(tempPath)
|
||||
defer tempInputFile.Close()
|
||||
|
||||
if _, err = io.Copy(tempInputFile, es); err != nil {
|
||||
return &Result{Path: tempPath}, fmt.Errorf("failed to write input file: %w", err)
|
||||
}
|
||||
|
||||
tempInputFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
w, h := v.settings.ThumbSize(ctx)
|
||||
cmd := exec.CommandContext(ctx,
|
||||
v.settings.VipsPath(ctx), "thumbnail_source", input, outputOpt, strconv.Itoa(w),
|
||||
"--height", strconv.Itoa(h))
|
||||
|
||||
tempPath := filepath.Join(
|
||||
util.DataPath(v.settings.TempPath(ctx)),
|
||||
thumbTempFolder,
|
||||
fmt.Sprintf("thumb_%s", uuid.Must(uuid.NewV4()).String()),
|
||||
)
|
||||
|
||||
thumbFile, err := util.CreatNestedFile(tempPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
defer thumbFile.Close()
|
||||
|
||||
// Redirect IO
|
||||
var vipsErr bytes.Buffer
|
||||
if usePipe {
|
||||
cmd.Stdin = es
|
||||
}
|
||||
cmd.Stdout = thumbFile
|
||||
cmd.Stderr = &vipsErr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
v.l.Warning("Failed to invoke vips: %s", vipsErr.String())
|
||||
return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s", err, vipsErr.String())
|
||||
}
|
||||
|
||||
return &Result{Path: tempPath}, nil
|
||||
}
|
||||
|
||||
func (v *VipsGenerator) Priority() int {
|
||||
return 100
|
||||
}
|
||||
|
||||
func (v *VipsGenerator) Enabled(ctx context.Context) bool {
|
||||
return v.settings.VipsThumbGeneratorEnabled(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user