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:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
+169
View File
@@ -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
}