Support GIF videos in your photo gallery
This commit is contained in:
parent
8bd1fac3ee
commit
e051da21b5
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
93
pkg/photo/gifv.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -60,6 +60,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
"UrlEncode": UrlEncode,
|
||||
"QueryPlus": QueryPlus(r),
|
||||
"SimplePager": SimplePager(r),
|
||||
"HasSuffix": strings.HasSuffix,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue
Block a user