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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user