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.
170 lines
4.1 KiB
Go
170 lines
4.1 KiB
Go
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
|
|
}
|