Support GIF videos in your photo gallery

This commit is contained in:
Noah Petherbridge 2023-06-25 22:55:07 -07:00
parent 8bd1fac3ee
commit e051da21b5
10 changed files with 190 additions and 21 deletions

View File

@ -33,6 +33,7 @@ const (
CSRFInputName = "_csrf" // html input name
SessionCookieMaxAge = 60 * 60 * 24 * 30
SessionRedisKeyFormat = "session/%s"
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
)

View File

@ -3,6 +3,7 @@ package photo
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
@ -67,6 +68,13 @@ func Edit() http.HandlerFunc {
photo.Gallery = isGallery
photo.Visibility = visibility
// Can not use a GIF as profile pic.
if setProfilePic && filepath.Ext(photo.Filename) == ".gif" {
session.FlashError(w, r, "You can not use a GIF as your profile picture.")
templates.Redirect(w, "/")
return
}
// Are we cropping ourselves a new profile pic?
log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop)
if setProfilePic && crop != nil && len(crop) >= 4 {
@ -82,6 +90,8 @@ func Edit() http.HandlerFunc {
photo.CroppedFilename = cropFilename
log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo)
}
} else {
setProfilePic = false
}
log.Error("SAVING PHOTO: %+v", photo)

View File

@ -94,6 +94,13 @@ func Upload() http.HandlerFunc {
return
}
// GIF can not be uploaded for a profile picture.
if filepath.Ext(header.Filename) == ".gif" && vars["Intent"] == "profile_pic" {
session.FlashError(w, r, "GIF images are not acceptable for your profile picture.")
templates.Redirect(w, r.URL.Path)
return
}
// Read the file contents.
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
var buf bytes.Buffer

View File

@ -26,7 +26,19 @@ func CSRF(handler http.Handler) http.Handler {
// If we are running a POST request, validate the CSRF form value.
if r.Method != http.MethodGet {
r.ParseMultipartForm(config.MultipartMaxMemory)
r.Body = http.MaxBytesReader(w, r.Body, config.MaxBodySize)
err := r.ParseMultipartForm(config.MultipartMaxMemory)
if err != nil {
log.Error("ParseMultipartForm: %s", err)
templates.MakeErrorPage(
"Request Size Too Large",
"You have uploaded too big of a file! Go back and try something else.",
http.StatusNotAcceptable,
)(w, r.WithContext(ctx))
return
}
// Check for the CSRF token
check := r.FormValue(config.CSRFInputName)
if check != token {
log.Error("CSRF mismatch! %s <> %s", check, token)

93
pkg/photo/gifv.go Normal file
View File

@ -0,0 +1,93 @@
package photo
import (
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strings"
"code.nonshy.com/nonshy/website/pkg/log"
)
// GifToMP4 converts the image into an mp4 gifv. Requires the `ffmpeg` program to be installed, or returns an error.
func GifToMP4(filename string, cfg *UploadConfig) error {
var (
gifSize int64
mp4Size int64
)
// Write GIF to temp file
fh, err := os.CreateTemp("", "nonshy-*.gif")
if err != nil {
return err
}
defer os.Remove(fh.Name())
if n, err := fh.Write(cfg.Data); err != nil {
return err
} else {
gifSize = int64(n)
log.Debug("GifToMP4: written %d bytes to %s", gifSize, fh.Name())
}
// Prepare an mp4 tempfile to write to
var mp4 = strings.TrimSuffix(fh.Name(), ".gif") + ".mp4"
// Run ffmpeg
command := []string{
"ffmpeg",
"-i", fh.Name(), // .gif name
"-movflags", "faststart",
"-pix_fmt", "yuv420p",
"-vf", `scale=trunc(iw/2)*2:trunc(ih/2)*2`,
mp4, // .mp4 name
}
log.Debug("GifToMP4: Run command: %s", command)
cmd := exec.Command(command[0], command[1:]...)
if stdoutErr, err := cmd.CombinedOutput(); err != nil {
log.Error("ffmpeg failed:\n%s", stdoutErr)
return fmt.Errorf("GIF conversion didn't work (ffmpeg might not be installed): %s", err)
}
// Make sure the output file isn't empty.
if stat, err := os.Stat(mp4); !os.IsNotExist(err) {
mp4Size = stat.Size()
log.Debug("GifToMP4: stats of generated file %s: %d bytes", mp4, mp4Size)
if stat.Size() == 0 {
return errors.New("GIF conversion failed: output mp4 file was empty")
}
}
// Place the .mp4 (not .gif) in the static/photos/ folder
if !strings.HasSuffix(filename, ".mp4") {
filename = strings.TrimSuffix(filename, ".gif") + ".mp4"
}
if path, err := EnsurePath(filename); err == nil {
// Copy the mp4 tempfile into the right place
srcFile, err := ioutil.ReadFile(mp4)
if err != nil {
return err
}
destFile, err := os.Create(path)
if err != nil {
return err
}
defer destFile.Close()
w, err := destFile.Write(srcFile)
log.Debug("GifToMP4: Copy tempfile %s => %s; w=%d err=%s", mp4, path, w, err)
}
log.Info("GifToMP4: converted GIF (%d bytes, %f MB) to MP4 (%d bytes, %f MB) for a %f%% savings",
gifSize, float64(gifSize)/1024/1024,
mp4Size, float64(mp4Size)/1024/1024,
float64(gifSize)/float64(mp4Size),
)
return nil
}

View File

@ -45,6 +45,8 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
extension = ".jpg"
case ".png":
extension = ".png"
case ".gif":
extension = ".mp4"
default:
return "", "", errors.New("unsupported image extension, must be jpg or png")
}
@ -93,7 +95,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear)
// Write the image to disk.
if err := ToDisk(filename, extension, scaledImg); err != nil {
if err := ToDisk(filename, extension, scaledImg, &cfg); err != nil {
return "", "", err
}
@ -111,7 +113,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
// Write that to disk, too.
log.Debug("Writing cropped image to disk: %s", cropFilename)
if err := ToDisk(cropFilename, extension, croppedImg); err != nil {
if err := ToDisk(cropFilename, extension, croppedImg, &cfg); err != nil {
return filename, "", err
}
@ -181,7 +183,7 @@ func ReCrop(filename string, x, y, w, h int) (string, error) {
croppedImg := Crop(img, x, y, w, h)
// Write it.
err = ToDisk(cropFilename, ext, croppedImg)
err = ToDisk(cropFilename, ext, croppedImg, nil)
return cropFilename, err
}
@ -208,7 +210,20 @@ func ParseCropCoords(coords string) []int {
// ToDisk commits a photo image to disk in the right file format.
//
// Filename is like NewFilename() and it goes to e.g. "./web/static/photos/"
func ToDisk(filename string, extension string, img image.Image) error {
//
// Encoding rules:
// - JPEG and PNG uploads are saved as JPEG
// - GIF uploads are transmuted to MP4
func ToDisk(filename string, extension string, img image.Image, cfg *UploadConfig) error {
// GIF path handled specially (note: it will come in with extension=".mp4")
if extension == ".gif" || extension == ".mp4" {
// Requires an upload config (ReCrop not supported)
if cfg == nil {
return errors.New("can't update your GIF after original upload")
}
return GifToMP4(filename, cfg)
}
if path, err := EnsurePath(filename); err == nil {
fh, err := os.Create(path)
if err != nil {
@ -217,12 +232,10 @@ func ToDisk(filename string, extension string, img image.Image) error {
defer fh.Close()
switch extension {
case ".jpg":
case ".jpg", ".png":
jpeg.Encode(fh, img, &jpeg.Options{
Quality: config.JpegQuality,
})
case ".png":
png.Encode(fh, img)
}
} else {
return fmt.Errorf("couldn't EnsurePath: %s", err)

View File

@ -60,6 +60,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"UrlEncode": UrlEncode,
"QueryPlus": QueryPlus(r),
"SimplePager": SimplePager(r),
"HasSuffix": strings.HasSuffix,
}
}

View File

@ -213,9 +213,16 @@
<span class="tag is-dark">photo expired on {{.ExpiredAt.Format "2006-01-02"}}</span>
</div>
{{else}}
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video autoplay loop controls>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<div class="image mt-4">
<img src="{{PhotoURL .Filename}}">
</div>
{{end}}
{{end}}
{{end}}
{{end}}

View File

@ -389,10 +389,15 @@
{{end}}
</header>
<div class="card-image">
<figure class="image">
<img src="{{PhotoURL .Filename}}">
</figure>
<div class="card-image has-text-centered">
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video autoplay loop controls>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<img src="{{PhotoURL .Filename}}">
{{end}}
</div>
<div class="card-content">
@ -490,14 +495,19 @@
</header>
{{end}}
<div class="card-image">
<figure class="image">
<a href="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal"
onclick="setModalImage(this.href)">
<img src="{{PhotoURL .Filename}}">
</a>
</figure>
<div class="card-image has-text-centered">
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video autoplay loop controls>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<a href="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal"
onclick="setModalImage(this.href)">
<img src="{{PhotoURL .Filename}}">
</a>
{{end}}
</div>
<div class="card-content">
{{if .Caption}}

View File

@ -129,7 +129,7 @@
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png"
accept=".jpg,.jpeg,.jpe,.png{{if ne .Intent "profile_pic"}},.gif{{end}}"
required>
<span class="file-cta">
<span class="file-icon">
@ -175,15 +175,24 @@
{{else}}<!-- when .EditPhoto -->
<div class="card-content">
<figure id="editphoto-preview" class="image block">
{{if HasSuffix .EditPhoto.Filename ".mp4"}}
<video autoplay loop controls>
<source src="{{PhotoURL .EditPhoto.Filename}}" type="video/mp4">
</video>
{{else}}
<img src="{{PhotoURL .EditPhoto.Filename}}" id="editphoto-img">
{{end}}
</figure>
<!-- Re-crop as profile picture: not when it's a GIF -->
{{if not (HasSuffix .EditPhoto.Filename ".mp4")}}
<div class="block has-text-centered" id="editphoto-begin-crop">
<button type="button" class="button" onclick="setProfilePhoto()">
<span class="icon"><i class="fa fa-crop-simple"></i></span>
<span>Set this as my profile photo (crop image)</span>
</button>
</div>
{{end}}
<div class="block has-text-centered" id="editphoto-cropping" style="display: none">
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
@ -425,6 +434,12 @@
// Common handler for file selection, either via input
// field or drag/drop onto the page.
let onFile = (file) => {
// Too large? (8 MB GIFs) - NOTE: also see config.go so this matches.
if (file.size >= 1024*1024*8) {
window.alert("That file is too large! Choose something less than 8 MB.");
return;
}
$fileName.innerHTML = file.name;
// Read the image to show the preview on-page.