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
|
CSRFInputName = "_csrf" // html input name
|
||||||
SessionCookieMaxAge = 60 * 60 * 24 * 30
|
SessionCookieMaxAge = 60 * 60 * 24 * 30
|
||||||
SessionRedisKeyFormat = "session/%s"
|
SessionRedisKeyFormat = "session/%s"
|
||||||
|
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
|
||||||
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ package photo
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
@ -67,6 +68,13 @@ func Edit() http.HandlerFunc {
|
||||||
photo.Gallery = isGallery
|
photo.Gallery = isGallery
|
||||||
photo.Visibility = visibility
|
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?
|
// Are we cropping ourselves a new profile pic?
|
||||||
log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop)
|
log.Error("Profile pic? %+v and crop is: %+v", setProfilePic, crop)
|
||||||
if setProfilePic && crop != nil && len(crop) >= 4 {
|
if setProfilePic && crop != nil && len(crop) >= 4 {
|
||||||
|
@ -82,6 +90,8 @@ func Edit() http.HandlerFunc {
|
||||||
photo.CroppedFilename = cropFilename
|
photo.CroppedFilename = cropFilename
|
||||||
log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo)
|
log.Warn("HERE WE SET (%s) ON PHOTO (%+v)", cropFilename, photo)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setProfilePic = false
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Error("SAVING PHOTO: %+v", photo)
|
log.Error("SAVING PHOTO: %+v", photo)
|
||||||
|
|
|
@ -94,6 +94,13 @@ func Upload() http.HandlerFunc {
|
||||||
return
|
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.
|
// Read the file contents.
|
||||||
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
|
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
|
||||||
var buf bytes.Buffer
|
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 we are running a POST request, validate the CSRF form value.
|
||||||
if r.Method != http.MethodGet {
|
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)
|
check := r.FormValue(config.CSRFInputName)
|
||||||
if check != token {
|
if check != token {
|
||||||
log.Error("CSRF mismatch! %s <> %s", 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"
|
extension = ".jpg"
|
||||||
case ".png":
|
case ".png":
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
|
case ".gif":
|
||||||
|
extension = ".mp4"
|
||||||
default:
|
default:
|
||||||
return "", "", errors.New("unsupported image extension, must be jpg or png")
|
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)
|
scaledImg := Scale(origImage, image.Rect(0, 0, width, height), draw.ApproxBiLinear)
|
||||||
|
|
||||||
// Write the image to disk.
|
// Write the image to disk.
|
||||||
if err := ToDisk(filename, extension, scaledImg); err != nil {
|
if err := ToDisk(filename, extension, scaledImg, &cfg); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +113,7 @@ func UploadPhoto(cfg UploadConfig) (string, string, error) {
|
||||||
|
|
||||||
// Write that to disk, too.
|
// Write that to disk, too.
|
||||||
log.Debug("Writing cropped image to disk: %s", cropFilename)
|
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
|
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)
|
croppedImg := Crop(img, x, y, w, h)
|
||||||
|
|
||||||
// Write it.
|
// Write it.
|
||||||
err = ToDisk(cropFilename, ext, croppedImg)
|
err = ToDisk(cropFilename, ext, croppedImg, nil)
|
||||||
return cropFilename, err
|
return cropFilename, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,7 +210,20 @@ func ParseCropCoords(coords string) []int {
|
||||||
// ToDisk commits a photo image to disk in the right file format.
|
// 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/"
|
// 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 {
|
if path, err := EnsurePath(filename); err == nil {
|
||||||
fh, err := os.Create(path)
|
fh, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -217,12 +232,10 @@ func ToDisk(filename string, extension string, img image.Image) error {
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
|
|
||||||
switch extension {
|
switch extension {
|
||||||
case ".jpg":
|
case ".jpg", ".png":
|
||||||
jpeg.Encode(fh, img, &jpeg.Options{
|
jpeg.Encode(fh, img, &jpeg.Options{
|
||||||
Quality: config.JpegQuality,
|
Quality: config.JpegQuality,
|
||||||
})
|
})
|
||||||
case ".png":
|
|
||||||
png.Encode(fh, img)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("couldn't EnsurePath: %s", err)
|
return fmt.Errorf("couldn't EnsurePath: %s", err)
|
||||||
|
|
|
@ -60,6 +60,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"UrlEncode": UrlEncode,
|
"UrlEncode": UrlEncode,
|
||||||
"QueryPlus": QueryPlus(r),
|
"QueryPlus": QueryPlus(r),
|
||||||
"SimplePager": SimplePager(r),
|
"SimplePager": SimplePager(r),
|
||||||
|
"HasSuffix": strings.HasSuffix,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -212,6 +212,12 @@
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<span class="tag is-dark">photo expired on {{.ExpiredAt.Format "2006-01-02"}}</span>
|
<span class="tag is-dark">photo expired on {{.ExpiredAt.Format "2006-01-02"}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<!-- GIF video? -->
|
||||||
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
|
<video autoplay loop controls>
|
||||||
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
|
</video>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="image mt-4">
|
<div class="image mt-4">
|
||||||
<img src="{{PhotoURL .Filename}}">
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
@ -219,6 +225,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<hr class="has-background-grey mb-2">
|
<hr class="has-background-grey mb-2">
|
||||||
|
|
||||||
|
|
|
@ -389,10 +389,15 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-image">
|
<div class="card-image has-text-centered">
|
||||||
<figure class="image">
|
<!-- GIF video? -->
|
||||||
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
|
<video autoplay loop controls>
|
||||||
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
{{else}}
|
||||||
<img src="{{PhotoURL .Filename}}">
|
<img src="{{PhotoURL .Filename}}">
|
||||||
</figure>
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
@ -490,14 +495,19 @@
|
||||||
</header>
|
</header>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="card-image">
|
<div class="card-image has-text-centered">
|
||||||
<figure class="image">
|
<!-- 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"
|
<a href="{{PhotoURL .Filename}}" target="_blank"
|
||||||
class="js-modal-trigger" data-target="detail-modal"
|
class="js-modal-trigger" data-target="detail-modal"
|
||||||
onclick="setModalImage(this.href)">
|
onclick="setModalImage(this.href)">
|
||||||
<img src="{{PhotoURL .Filename}}">
|
<img src="{{PhotoURL .Filename}}">
|
||||||
</a>
|
</a>
|
||||||
</figure>
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .Caption}}
|
{{if .Caption}}
|
||||||
|
|
|
@ -129,7 +129,7 @@
|
||||||
<input class="file-input" type="file"
|
<input class="file-input" type="file"
|
||||||
name="file"
|
name="file"
|
||||||
id="file"
|
id="file"
|
||||||
accept=".jpg,.jpeg,.jpe,.png"
|
accept=".jpg,.jpeg,.jpe,.png{{if ne .Intent "profile_pic"}},.gif{{end}}"
|
||||||
required>
|
required>
|
||||||
<span class="file-cta">
|
<span class="file-cta">
|
||||||
<span class="file-icon">
|
<span class="file-icon">
|
||||||
|
@ -175,15 +175,24 @@
|
||||||
{{else}}<!-- when .EditPhoto -->
|
{{else}}<!-- when .EditPhoto -->
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<figure id="editphoto-preview" class="image block">
|
<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">
|
<img src="{{PhotoURL .EditPhoto.Filename}}" id="editphoto-img">
|
||||||
|
{{end}}
|
||||||
</figure>
|
</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">
|
<div class="block has-text-centered" id="editphoto-begin-crop">
|
||||||
<button type="button" class="button" onclick="setProfilePhoto()">
|
<button type="button" class="button" onclick="setProfilePhoto()">
|
||||||
<span class="icon"><i class="fa fa-crop-simple"></i></span>
|
<span class="icon"><i class="fa fa-crop-simple"></i></span>
|
||||||
<span>Set this as my profile photo (crop image)</span>
|
<span>Set this as my profile photo (crop image)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="block has-text-centered" id="editphoto-cropping" style="display: none">
|
<div class="block has-text-centered" id="editphoto-cropping" style="display: none">
|
||||||
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
|
<button type="button" class="button block is-info" onclick="resetCrop()">Reset</button>
|
||||||
|
@ -425,6 +434,12 @@
|
||||||
// Common handler for file selection, either via input
|
// Common handler for file selection, either via input
|
||||||
// field or drag/drop onto the page.
|
// field or drag/drop onto the page.
|
||||||
let onFile = (file) => {
|
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;
|
$fileName.innerHTML = file.name;
|
||||||
|
|
||||||
// Read the image to show the preview on-page.
|
// Read the image to show the preview on-page.
|
||||||
|
|
Loading…
Reference in New Issue
Block a user