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
+70
View File
@@ -0,0 +1,70 @@
package video
import (
"bytes"
"context"
"fmt"
"os/exec"
"strconv"
"strings"
)
// RemuxArgs builds the ffmpeg arguments for a lossless copy into a faststart
// mp4. Streams are limited to the first video and optional first audio track;
// subtitles and extra tracks are dropped so players do not stumble on them.
func RemuxArgs(in, out string) []string {
return []string{
"-hide_banner", "-nostdin", "-y",
"-i", in,
"-map", "0:v:0", "-map", "0:a:0?",
"-c", "copy",
"-movflags", "+faststart",
out,
}
}
// TranscodeArgs builds the ffmpeg arguments for a browser-safe h264/aac mp4.
// The video is scaled to fit maxLongSide without upscaling and forced to
// even dimensions; ffmpeg applies display rotation during re-encoding.
func TranscodeArgs(in, out string, maxLongSide int) []string {
scale := fmt.Sprintf(
"scale=w='min(%d,iw)':h='min(%d,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
maxLongSide, maxLongSide)
return []string{
"-hide_banner", "-nostdin", "-y",
"-i", in,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-vf", scale,
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
out,
}
}
// PosterArgs builds the ffmpeg arguments to extract a single poster frame at
// atSecond, already scaled so downstream image pipelines decode a bounded frame.
func PosterArgs(in, out string, atSecond int) []string {
scale := fmt.Sprintf("scale=w='min(%d,iw)':h=-2", MaxLongSide)
return []string{
"-hide_banner", "-nostdin", "-y",
"-ss", strconv.Itoa(atSecond),
"-i", in,
"-frames:v", "1",
"-vf", scale,
"-f", "image2",
out,
}
}
// Run executes ffmpeg with the given arguments and returns an error carrying
// the captured stderr on failure.
func Run(ctx context.Context, ffmpegPath string, args ...string) error {
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var errb bytes.Buffer
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package video
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func argsContain(args []string, want ...string) bool {
for _, w := range want {
found := false
for _, a := range args {
if a == w {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func TestRemuxArgs(t *testing.T) {
args := RemuxArgs("in.mkv", "out.mp4")
if !argsContain(args, "-c", "copy", "-movflags", "+faststart", "-map", "0:a:0?") {
t.Fatalf("remux args = %v", args)
}
}
func TestTranscodeArgs(t *testing.T) {
args := TranscodeArgs("in.mov", "out.mp4", MaxLongSide)
if !argsContain(args, "libx264", "yuv420p", "aac", "-movflags", "+faststart") {
t.Fatalf("transcode args = %v", args)
}
var scale string
for i, a := range args {
if a == "-vf" && i+1 < len(args) {
scale = args[i+1]
}
}
if !strings.Contains(scale, "min(1920,iw)") || !strings.Contains(scale, "force_divisible_by=2") {
t.Fatalf("scale filter = %q", scale)
}
}
func TestPosterArgs(t *testing.T) {
args := PosterArgs("in.mp4", "poster.jpg", 1)
if !argsContain(args, "-ss", "1", "-frames:v", "1", "-f", "image2") {
t.Fatalf("poster args = %v", args)
}
}
func TestRun(t *testing.T) {
dir := t.TempDir()
ok := filepath.Join(dir, "ok")
if err := os.WriteFile(ok, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write ok: %v", err)
}
if err := Run(context.Background(), ok, "-i", "x"); err != nil {
t.Fatalf("run ok: %v", err)
}
bad := filepath.Join(dir, "bad")
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho nope >&2\nexit 3\n"), 0o755); err != nil {
t.Fatalf("write bad: %v", err)
}
if err := Run(context.Background(), bad); err == nil || !strings.Contains(err.Error(), "nope") {
t.Fatalf("run bad err = %v, want stderr captured", err)
}
}
+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
}
+95
View File
@@ -0,0 +1,95 @@
package video
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestCanRemux(t *testing.T) {
base := func() *Info {
return &Info{
VideoCodec: "h264",
PixelFormat: "yuv420p",
Profile: "High",
AudioCodec: "aac",
Container: "mov,mp4,m4a,3gp,3g2,mj2",
Width: 1920,
Height: 1080,
}
}
cases := []struct {
name string
mut func(*Info)
cap int
want bool
}{
{"web compatible", func(*Info) {}, MaxLongSide, true},
{"no audio", func(i *Info) { i.AudioCodec = "" }, MaxLongSide, true},
{"empty profile accepted", func(i *Info) { i.Profile = "" }, MaxLongSide, true},
{"hevc", func(i *Info) { i.VideoCodec = "hevc" }, MaxLongSide, false},
{"vp9", func(i *Info) { i.VideoCodec = "vp9" }, MaxLongSide, false},
{"yuv444p", func(i *Info) { i.PixelFormat = "yuv444p" }, MaxLongSide, false},
{"ten bit profile", func(i *Info) { i.Profile = "High 10" }, MaxLongSide, false},
{"mp3 audio", func(i *Info) { i.AudioCodec = "mp3" }, MaxLongSide, false},
{"mpegts container", func(i *Info) { i.Container = "mpegts" }, MaxLongSide, false},
{"mkv h264 remuxable", func(i *Info) { i.Container = "matroska,webm" }, MaxLongSide, true},
{"over cap", func(i *Info) { i.Width = 3840; i.Height = 2160 }, MaxLongSide, false},
{"cap disabled", func(i *Info) { i.Width = 3840; i.Height = 2160 }, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
i := base()
tc.mut(i)
if got := i.CanRemux(tc.cap); got != tc.want {
t.Fatalf("CanRemux = %v, want %v (%+v)", got, tc.want, i)
}
})
}
}
func writeFakeTool(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "ffprobe")
script := "#!/bin/sh\ncat <<'JSON'\n" + body + "\nJSON\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake tool: %v", err)
}
return path
}
func TestProbeParsesOutput(t *testing.T) {
probe := writeFakeTool(t, `{"streams":[{"codec_type":"video","codec_name":"h264","profile":"High","pix_fmt":"yuv420p","width":1920,"height":1080,"side_data_list":[{"rotation":-90}]},{"codec_type":"audio","codec_name":"aac"}],"format":{"format_name":"mov,mp4,m4a,3gp,3g2,mj2","duration":"12.345678"}}`)
info, err := Probe(context.Background(), probe, "input.mp4")
if err != nil {
t.Fatalf("probe: %v", err)
}
if info.VideoCodec != "h264" || info.PixelFormat != "yuv420p" || info.AudioCodec != "aac" {
t.Fatalf("codecs = %+v", info)
}
if info.Width != 1920 || info.Height != 1080 || info.LongSide() != 1920 {
t.Fatalf("dimensions = %+v", info)
}
if info.Rotation != 270 {
t.Fatalf("rotation = %d, want 270", info.Rotation)
}
if info.DurationMS != 12346 {
t.Fatalf("duration = %d, want 12346", info.DurationMS)
}
if !info.CanRemux(MaxLongSide) {
t.Fatalf("expected remuxable, got %+v", info)
}
}
func TestProbeFailsOnNonZeroExit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ffprobe")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho boom >&2\nexit 1\n"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := Probe(context.Background(), path, "x"); err == nil {
t.Fatal("expected probe error")
}
}
+77
View File
@@ -0,0 +1,77 @@
// Package video prepares uploaded video for browser playback using the external
// ffmpeg/ffprobe toolchain. Availability is detected at startup; when the tools
// are absent the rest of the application keeps working and video upload is
// disabled.
package video
import (
"os/exec"
"galo/internal/config"
)
// Toolchain locates the external video tools and reports whether they are
// usable. A nil Toolchain is treated as unavailable.
type Toolchain struct {
ffmpegPath string
ffprobePath string
tempDir string
available bool
}
// Detect resolves the configured tool paths and records whether both binaries
// are present. Missing tools are not an error: video is simply unavailable.
func Detect(cfg config.Video) *Toolchain {
t := &Toolchain{tempDir: cfg.TempDir}
if p, err := exec.LookPath(cfg.FFmpegPath); err == nil {
t.ffmpegPath = p
} else {
t.ffmpegPath = cfg.FFmpegPath
}
if p, err := exec.LookPath(cfg.FFprobePath); err == nil {
t.ffprobePath = p
} else {
t.ffprobePath = cfg.FFprobePath
}
t.available = t.ffmpegPath != "" && t.ffprobePath != "" &&
isExecutable(t.ffmpegPath) && isExecutable(t.ffprobePath)
return t
}
func isExecutable(path string) bool {
if path == "" {
return false
}
if _, err := exec.LookPath(path); err == nil {
return true
}
return false
}
// Available reports whether both ffmpeg and ffprobe are usable.
func (t *Toolchain) Available() bool { return t != nil && t.available }
// FFmpegPath returns the resolved ffmpeg binary path.
func (t *Toolchain) FFmpegPath() string {
if t == nil {
return ""
}
return t.ffmpegPath
}
// FFprobePath returns the resolved ffprobe binary path.
func (t *Toolchain) FFprobePath() string {
if t == nil {
return ""
}
return t.ffprobePath
}
// TempDir returns the configured temporary directory (empty means the system
// default).
func (t *Toolchain) TempDir() string {
if t == nil {
return ""
}
return t.tempDir
}
+45
View File
@@ -0,0 +1,45 @@
package video
import (
"os"
"testing"
"galo/internal/config"
)
func TestDetectUnavailable(t *testing.T) {
tc := Detect(config.Video{
FFmpegPath: "definitely-missing-ffmpeg-xyz",
FFprobePath: "definitely-missing-ffprobe-xyz",
})
if tc.Available() {
t.Fatalf("expected unavailable toolchain, got %+v", tc)
}
}
func TestDetectAvailable(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Fatalf("executable: %v", err)
}
tc := Detect(config.Video{FFmpegPath: self, FFprobePath: self, TempDir: "/tmp"})
if !tc.Available() {
t.Fatalf("expected available toolchain, got %+v", tc)
}
if tc.FFmpegPath() != self || tc.FFprobePath() != self {
t.Fatalf("paths = %q %q", tc.FFmpegPath(), tc.FFprobePath())
}
if tc.TempDir() != "/tmp" {
t.Fatalf("temp dir = %q", tc.TempDir())
}
}
func TestNilToolchain(t *testing.T) {
var tc *Toolchain
if tc.Available() {
t.Fatal("nil toolchain must be unavailable")
}
if tc.FFmpegPath() != "" || tc.FFprobePath() != "" || tc.TempDir() != "" {
t.Fatal("nil toolchain accessors must be empty")
}
}