Make photo library offline-first and fix video thumbnails
Server / deployment-config (push) Successful in 59s
Android / test-and-build (push) Failing after 1m14s
Server / vulnerability-scan (push) Failing after 8m54s

This commit is contained in:
Курнат Андрей
2026-07-19 21:01:35 +03:00
parent 54556b76e9
commit 7135199f23
52 changed files with 2424 additions and 464 deletions
@@ -4,15 +4,20 @@ import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
"image/draw"
"image/gif"
_ "image/jpeg"
"io"
"math"
"mime"
"os"
"os/exec"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/image/font"
@@ -41,6 +46,59 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
return img, nil
}
// VideoDecoder extracts a bounded preview frame with ffmpeg. The input is first persisted to a
// temporary file so ffmpeg can seek to MP4/MOV metadata stored at the end of large camera files.
// The file lives on disk and is never retained in process memory.
type VideoDecoder struct{}
func (VideoDecoder) Convert(r io.Reader) (any, error) {
input, err := os.CreateTemp("", "qsfera-video-*")
if err != nil {
return nil, err
}
inputPath := input.Name()
defer os.Remove(inputPath)
if _, err = io.Copy(input, r); err != nil {
input.Close()
return nil, errors.Wrap(err, "could not persist video for preview extraction")
}
if err = input.Sync(); err != nil {
input.Close()
return nil, err
}
if err = input.Close(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), videoFrameExtractionTimeout)
defer cancel()
frame, err := extractVideoFrame(ctx, inputPath, nil)
if err != nil {
return nil, errors.Wrap(err, "could not extract video preview frame")
}
decoded, _, err := image.Decode(bytes.NewReader(frame))
if err != nil {
return nil, errors.Wrap(err, "could not decode video preview frame")
}
return decoded, nil
}
const videoFrameExtractionTimeout = 90 * time.Second
func extractVideoFrame(ctx context.Context, input string, stdin io.Reader) ([]byte, error) {
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-hide_banner", "-loglevel", "error", "-i", input,
"-map", "0:v:0", "-an", "-sn", "-frames:v", "1",
"-vf", "thumbnail=30,scale=1920:-2:force_original_aspect_ratio=decrease",
"-f", "image2pipe", "-vcodec", "png", "pipe:1",
)
cmd.Stdin = stdin
return cmd.Output()
}
// GgsDecoder is a converter for the geogebra slides file
type GgsDecoder struct{ thumbnailpath string }
@@ -303,6 +361,9 @@ func ForType(mimeType string, opts map[string]any) FileConverter {
// return the service call. So we should only get here when the mimeType parses fine.
mimeType, _, _ = mime.ParseMediaType(mimeType)
switch mimeType {
case "video/mp4", "video/quicktime", "video/webm", "video/x-matroska", "video/x-msvideo", "video/mpeg", "video/3gpp",
"video/x-m4v", "video/mp2t", "video/ogg", "video/x-ms-wmv", "video/x-flv", "video/hevc":
return VideoDecoder{}
case "text/plain":
fontFileMap := ""
fontFaceOpts := &opentype.FaceOptions{