feat(video): add video upload with remux/transcode playback
Add video as a media kind in photos: streaming upload with per-kind limits, ffprobe metadata, poster frame reusing the image variant pipeline, and a playback object (remux when web-compatible, else h264/yuv420p/aac transcode). Video jobs run on a separate pool with a long lease. Frontend gains a video viewer, grid badge/duration and video upload.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxLongSide is the maximum long side of a prepared playback variant. Sources
|
||||
// larger than this are scaled down; smaller sources are never upscaled.
|
||||
const MaxLongSide = 1920
|
||||
|
||||
// Info describes the media facts ffprobe reports for a video file.
|
||||
type Info struct {
|
||||
DurationMS int64
|
||||
Width int
|
||||
Height int
|
||||
Rotation int
|
||||
VideoCodec string
|
||||
PixelFormat string
|
||||
Profile string
|
||||
AudioCodec string
|
||||
Container string
|
||||
}
|
||||
|
||||
type ffprobeStream struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Profile string `json:"profile"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
SideDataList []struct {
|
||||
Rotation float64 `json:"rotation"`
|
||||
} `json:"side_data_list"`
|
||||
}
|
||||
|
||||
type ffprobeOutput struct {
|
||||
Streams []ffprobeStream `json:"streams"`
|
||||
Format struct {
|
||||
FormatName string `json:"format_name"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
// Probe runs ffprobe and returns the parsed media facts.
|
||||
func Probe(ctx context.Context, ffprobePath, path string) (*Info, error) {
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("ffprobe: %w: %s", err, strings.TrimSpace(errb.String()))
|
||||
}
|
||||
var parsed ffprobeOutput
|
||||
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse ffprobe output: %w", err)
|
||||
}
|
||||
info := &Info{Container: parsed.Format.FormatName}
|
||||
for _, st := range parsed.Streams {
|
||||
switch st.CodecType {
|
||||
case "video":
|
||||
if info.VideoCodec == "" {
|
||||
info.VideoCodec = st.CodecName
|
||||
info.Profile = st.Profile
|
||||
info.PixelFormat = st.PixFmt
|
||||
info.Width = st.Width
|
||||
info.Height = st.Height
|
||||
info.Rotation = rotationOf(st.SideDataList, st.Tags)
|
||||
}
|
||||
case "audio":
|
||||
if info.AudioCodec == "" {
|
||||
info.AudioCodec = st.CodecName
|
||||
}
|
||||
}
|
||||
}
|
||||
info.DurationMS = durationMS(parsed.Format.Duration)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func durationMS(seconds string) int64 {
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(seconds), 64)
|
||||
if err != nil || f < 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(f*1000 + 0.5)
|
||||
}
|
||||
|
||||
func rotationOf(sd []struct {
|
||||
Rotation float64 `json:"rotation"`
|
||||
}, tags map[string]string) int {
|
||||
for _, s := range sd {
|
||||
if s.Rotation != 0 {
|
||||
return normalizeDegrees(int(s.Rotation))
|
||||
}
|
||||
}
|
||||
if tags != nil {
|
||||
if v, ok := tags["rotate"]; ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return normalizeDegrees(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeDegrees(n int) int {
|
||||
r := n % 360
|
||||
if r < 0 {
|
||||
r += 360
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
var allowedProfiles = map[string]bool{
|
||||
"baseline": true,
|
||||
"constrained baseline": true,
|
||||
"main": true,
|
||||
"high": true,
|
||||
}
|
||||
|
||||
// CanRemux reports whether the source can be copied into a web-friendly mp4
|
||||
// unchanged. It requires h264/yuv420p with a browser-supported profile, aac or
|
||||
// no audio, a remuxable container, and a resolution within the cap.
|
||||
func (i *Info) CanRemux(maxLongSide int) bool {
|
||||
if i == nil || i.VideoCodec != "h264" {
|
||||
return false
|
||||
}
|
||||
if i.PixelFormat != "yuv420p" {
|
||||
return false
|
||||
}
|
||||
if i.Profile != "" && !allowedProfiles[strings.ToLower(i.Profile)] {
|
||||
return false
|
||||
}
|
||||
if i.AudioCodec != "" && i.AudioCodec != "aac" {
|
||||
return false
|
||||
}
|
||||
if !containerRemuxable(i.Container) {
|
||||
return false
|
||||
}
|
||||
if maxLongSide > 0 && i.LongSide() > maxLongSide {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LongSide returns the larger of width and height.
|
||||
func (i *Info) LongSide() int {
|
||||
if i.Width > i.Height {
|
||||
return i.Width
|
||||
}
|
||||
return i.Height
|
||||
}
|
||||
|
||||
func containerRemuxable(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
for _, c := range []string{"mp4", "mov", "matroska", "webm"} {
|
||||
if strings.Contains(name, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user