567 lines
16 KiB
Go
567 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Stream struct {
|
|
Index int `json:"index"`
|
|
Type string `json:"type"`
|
|
Codec string `json:"codec"`
|
|
Profile string `json:"profile,omitempty"`
|
|
Width int `json:"width,omitempty"`
|
|
Height int `json:"height,omitempty"`
|
|
DisplayWidth int `json:"displayWidth,omitempty"` // after pixel aspect ratio and rotation
|
|
DisplayHeight int `json:"displayHeight,omitempty"`
|
|
SquarePixels bool `json:"squarePixels,omitempty"`
|
|
Rotation int `json:"rotation,omitempty"`
|
|
PixFmt string `json:"pixFmt,omitempty"`
|
|
FPS float64 `json:"fps,omitempty"`
|
|
FrameRate string `json:"frameRate,omitempty"` // rational, e.g. 30000/1001
|
|
SampleRate int `json:"sampleRate,omitempty"`
|
|
Channels int `json:"channels,omitempty"`
|
|
ChannelLayout string `json:"channelLayout,omitempty"`
|
|
Language string `json:"language,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
}
|
|
|
|
type Media struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ModTime time.Time `json:"modTime"`
|
|
Duration float64 `json:"duration"`
|
|
StartTime float64 `json:"startTime"`
|
|
Format string `json:"format"`
|
|
FormatLong string `json:"formatLong"`
|
|
BitRate int64 `json:"bitRate"`
|
|
Streams []Stream `json:"streams"`
|
|
Video *Stream `json:"video"` // primary video stream, nil if none
|
|
Audio []Stream `json:"audio"`
|
|
}
|
|
|
|
func (m *Media) FirstAudio() *Stream {
|
|
if len(m.Audio) == 0 {
|
|
return nil
|
|
}
|
|
return &m.Audio[0]
|
|
}
|
|
|
|
func mediaID(path string) string {
|
|
h := sha1.Sum([]byte(path))
|
|
return hex.EncodeToString(h[:6])
|
|
}
|
|
|
|
type ffprobeOutput struct {
|
|
Format struct {
|
|
FormatName string `json:"format_name"`
|
|
FormatLongName string `json:"format_long_name"`
|
|
Duration string `json:"duration"`
|
|
StartTime string `json:"start_time"`
|
|
BitRate string `json:"bit_rate"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
Index int `json:"index"`
|
|
CodecType string `json:"codec_type"`
|
|
CodecName string `json:"codec_name"`
|
|
Profile string `json:"profile"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
PixFmt string `json:"pix_fmt"`
|
|
SAR string `json:"sample_aspect_ratio"`
|
|
AvgFrameRate string `json:"avg_frame_rate"`
|
|
RFrameRate string `json:"r_frame_rate"`
|
|
SampleRate string `json:"sample_rate"`
|
|
Channels int `json:"channels"`
|
|
ChannelLayout string `json:"channel_layout"`
|
|
Duration string `json:"duration"`
|
|
Tags map[string]string `json:"tags"`
|
|
Disposition map[string]int `json:"disposition"`
|
|
SideData []map[string]any `json:"side_data_list"`
|
|
} `json:"streams"`
|
|
}
|
|
|
|
func (t *Tools) Probe(ctx context.Context, path string) (*Media, error) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st, err := os.Stat(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("file not found: %s", abs)
|
|
}
|
|
if st.IsDir() {
|
|
return nil, fmt.Errorf("%s is a folder", abs)
|
|
}
|
|
out, err := command(ctx, t.FFprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", abs).Output()
|
|
if err != nil {
|
|
var ee interface{ ExitCode() int }
|
|
if errors.As(err, &ee) {
|
|
return nil, fmt.Errorf("ffprobe could not read %s - is it a video file?", filepath.Base(abs))
|
|
}
|
|
return nil, err
|
|
}
|
|
var p ffprobeOutput
|
|
if err := json.Unmarshal(out, &p); err != nil {
|
|
return nil, fmt.Errorf("unexpected ffprobe output: %w", err)
|
|
}
|
|
m := &Media{
|
|
ID: mediaID(abs),
|
|
Path: abs,
|
|
Name: filepath.Base(abs),
|
|
Size: st.Size(),
|
|
ModTime: st.ModTime(),
|
|
Duration: parseFloat(p.Format.Duration),
|
|
StartTime: parseFloat(p.Format.StartTime),
|
|
Format: p.Format.FormatName,
|
|
FormatLong: p.Format.FormatLongName,
|
|
BitRate: int64(parseFloat(p.Format.BitRate)),
|
|
Streams: []Stream{},
|
|
Audio: []Stream{},
|
|
}
|
|
var maxStreamDur float64
|
|
for _, s := range p.Streams {
|
|
maxStreamDur = math.Max(maxStreamDur, parseFloat(s.Duration))
|
|
ms := Stream{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile,
|
|
Language: s.Tags["language"], Title: s.Tags["title"]}
|
|
switch s.CodecType {
|
|
case "video":
|
|
ms.Width, ms.Height, ms.PixFmt = s.Width, s.Height, s.PixFmt
|
|
ms.FrameRate, ms.FPS = pickFrameRate(s.AvgFrameRate, s.RFrameRate)
|
|
ms.Rotation = streamRotation(s.Tags, s.SideData)
|
|
sar := parseRatio(s.SAR, ":")
|
|
ms.SquarePixels = sar <= 0 || math.Abs(sar-1) < 0.001
|
|
dw, dh := float64(s.Width), float64(s.Height)
|
|
if !ms.SquarePixels {
|
|
dw *= sar
|
|
}
|
|
if ms.Rotation%180 != 0 {
|
|
dw, dh = dh, dw
|
|
}
|
|
ms.DisplayWidth, ms.DisplayHeight = int(math.Round(dw)), int(math.Round(dh))
|
|
if m.Video == nil && s.Disposition["attached_pic"] == 0 && s.Width > 0 {
|
|
v := ms
|
|
m.Video = &v
|
|
}
|
|
case "audio":
|
|
ms.SampleRate = int(parseFloat(s.SampleRate))
|
|
ms.Channels, ms.ChannelLayout = s.Channels, s.ChannelLayout
|
|
m.Audio = append(m.Audio, ms)
|
|
}
|
|
m.Streams = append(m.Streams, ms)
|
|
}
|
|
if m.Duration <= 0 {
|
|
m.Duration = maxStreamDur
|
|
}
|
|
if m.Video == nil && len(m.Audio) == 0 {
|
|
return nil, fmt.Errorf("%s has no video or audio streams", m.Name)
|
|
}
|
|
if m.Duration <= 0 {
|
|
return nil, fmt.Errorf("could not determine the duration of %s", m.Name)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func parseFloat(s string) float64 {
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
|
return 0
|
|
}
|
|
return f
|
|
}
|
|
|
|
func parseRatio(s, sep string) float64 {
|
|
a, b, ok := strings.Cut(s, sep)
|
|
if !ok {
|
|
return parseFloat(s)
|
|
}
|
|
num, den := parseFloat(a), parseFloat(b)
|
|
if den == 0 {
|
|
return 0
|
|
}
|
|
return num / den
|
|
}
|
|
|
|
// pickFrameRate prefers the nominal rate (r_frame_rate) when it agrees with the
|
|
// average, since containers like MKV report odd r_frame_rate values.
|
|
func pickFrameRate(avg, r string) (string, float64) {
|
|
a, rr := parseRatio(avg, "/"), parseRatio(r, "/")
|
|
switch {
|
|
case rr >= 1 && rr <= 240 && (a <= 0 || math.Abs(rr-a)/rr < 0.1):
|
|
return r, rr
|
|
case a >= 1 && a <= 240:
|
|
return avg, a
|
|
case rr > 0:
|
|
return r, rr
|
|
}
|
|
return "30", 30
|
|
}
|
|
|
|
func streamRotation(tags map[string]string, side []map[string]any) int {
|
|
rot := 0.0
|
|
if v, ok := tags["rotate"]; ok {
|
|
rot = parseFloat(v)
|
|
}
|
|
for _, sd := range side {
|
|
if v, ok := sd["rotation"].(float64); ok {
|
|
rot = v
|
|
}
|
|
}
|
|
r := int(math.Round(rot)) % 360
|
|
if r < 0 {
|
|
r += 360
|
|
}
|
|
return r
|
|
}
|
|
|
|
// --- MediaStore: probed sources, thumbnails, keyframes and preview proxies ---
|
|
|
|
type kfEntry struct {
|
|
status string // pending, done, error
|
|
times []float64
|
|
err string
|
|
done chan struct{}
|
|
}
|
|
|
|
type MediaStore struct {
|
|
tools *Tools
|
|
jobs *JobManager
|
|
|
|
mu sync.Mutex
|
|
items map[string]*Media
|
|
kf map[string]*kfEntry
|
|
|
|
kfSem chan struct{}
|
|
thumbSem chan struct{}
|
|
thumbs *byteCache
|
|
}
|
|
|
|
func newMediaStore(t *Tools, jobs *JobManager) *MediaStore {
|
|
return &MediaStore{
|
|
tools: t,
|
|
jobs: jobs,
|
|
items: map[string]*Media{},
|
|
kf: map[string]*kfEntry{},
|
|
kfSem: make(chan struct{}, 2),
|
|
thumbSem: make(chan struct{}, 3),
|
|
thumbs: newByteCache(96 << 20),
|
|
}
|
|
}
|
|
|
|
func (s *MediaStore) Get(id string) *Media {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.items[id]
|
|
}
|
|
|
|
func (a *App) handleProbe(w http.ResponseWriter, r *http.Request) {
|
|
if !a.tools.OK() {
|
|
writeError(w, http.StatusServiceUnavailable, "ffmpeg/ffprobe not found")
|
|
return
|
|
}
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := readJSON(r, &req); err != nil || strings.TrimSpace(req.Path) == "" {
|
|
writeError(w, http.StatusBadRequest, "missing path")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
m, err := a.tools.Probe(ctx, strings.TrimSpace(req.Path))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
a.media.mu.Lock()
|
|
a.media.items[m.ID] = m
|
|
a.media.mu.Unlock()
|
|
writeJSON(w, m)
|
|
}
|
|
|
|
func (a *App) handleMedia(w http.ResponseWriter, r *http.Request) {
|
|
m := a.media.Get(r.PathValue("id"))
|
|
if m == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
path, name := m.Path, m.Name
|
|
if r.URL.Query().Get("proxy") == "1" {
|
|
path, name = a.media.proxyPath(m), "proxy.mp4"
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// Browsers sniff media content; these types just keep them from refusing.
|
|
switch strings.ToLower(filepath.Ext(name)) {
|
|
case ".mp4", ".m4v", ".mov", ".3gp":
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
case ".mkv", ".webm":
|
|
w.Header().Set("Content-Type", "video/webm")
|
|
case ".ogv":
|
|
w.Header().Set("Content-Type", "video/ogg")
|
|
default:
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
}
|
|
http.ServeContent(w, r, name, st.ModTime(), f)
|
|
}
|
|
|
|
// handleThumb renders a single frame as JPEG.
|
|
func (a *App) handleThumb(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
m := a.media.Get(q.Get("id"))
|
|
if m == nil || m.Video == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
t := math.Max(0, math.Min(parseFloat(q.Get("t")), m.Duration-0.05))
|
|
h, _ := strconv.Atoi(q.Get("h"))
|
|
if h <= 0 || h > 1080 {
|
|
h = 90
|
|
}
|
|
h -= h % 2
|
|
key := fmt.Sprintf("%s|%.3f|%d", m.ID, t, h)
|
|
img := a.media.thumbs.Get(key)
|
|
if img == nil {
|
|
select {
|
|
case a.media.thumbSem <- struct{}{}:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
out, err := command(ctx, a.tools.FFmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",
|
|
"-ss", ffTime(t), "-i", m.Path, "-map", fmt.Sprintf("0:%d", m.Video.Index), "-frames:v", "1",
|
|
"-vf", fmt.Sprintf("scale=-2:%d", h), "-f", "image2pipe", "-c:v", "mjpeg", "-q:v", "5", "-").Output()
|
|
cancel()
|
|
<-a.media.thumbSem
|
|
if err != nil || len(out) == 0 {
|
|
http.Error(w, "could not render frame", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
img = out
|
|
a.media.thumbs.Put(key, img)
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
|
w.Write(img)
|
|
}
|
|
|
|
// handleKeyframes returns the keyframe times of the primary video stream,
|
|
// scanning the file in the background on first request.
|
|
func (a *App) handleKeyframes(w http.ResponseWriter, r *http.Request) {
|
|
m := a.media.Get(r.URL.Query().Get("id"))
|
|
if m == nil {
|
|
writeError(w, http.StatusNotFound, "unknown source")
|
|
return
|
|
}
|
|
if m.Video == nil {
|
|
writeJSON(w, map[string]any{"status": "done", "times": []float64{}})
|
|
return
|
|
}
|
|
s := a.media
|
|
e := s.keyframeEntry(m)
|
|
s.mu.Lock()
|
|
resp := map[string]any{"status": e.status, "error": e.err}
|
|
if e.status == "done" {
|
|
resp["times"] = e.times
|
|
}
|
|
s.mu.Unlock()
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// keyframeEntry returns the keyframe scan for a source, starting it if needed.
|
|
func (s *MediaStore) keyframeEntry(m *Media) *kfEntry {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
e := s.kf[m.ID]
|
|
if e == nil {
|
|
e = &kfEntry{status: "pending", done: make(chan struct{})}
|
|
s.kf[m.ID] = e
|
|
go s.scanKeyframes(m, e)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// Keyframes waits for the keyframe scan of a source to finish.
|
|
func (s *MediaStore) Keyframes(ctx context.Context, m *Media) ([]float64, error) {
|
|
if m.Video == nil {
|
|
return nil, nil
|
|
}
|
|
e := s.keyframeEntry(m)
|
|
select {
|
|
case <-e.done:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if e.status != "done" {
|
|
return nil, errors.New(e.err)
|
|
}
|
|
return e.times, nil
|
|
}
|
|
|
|
func (s *MediaStore) scanKeyframes(m *Media, e *kfEntry) {
|
|
s.kfSem <- struct{}{}
|
|
defer func() { <-s.kfSem }()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
out, err := command(ctx, s.tools.FFprobe, "-v", "error", "-select_streams", strconv.Itoa(m.Video.Index),
|
|
"-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", m.Path).Output()
|
|
var times []float64
|
|
if err == nil {
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
pts, flags, ok := strings.Cut(strings.TrimSpace(line), ",")
|
|
if !ok || !strings.HasPrefix(flags, "K") || pts == "N/A" {
|
|
continue
|
|
}
|
|
t, perr := strconv.ParseFloat(pts, 64)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
times = append(times, math.Max(0, t-m.StartTime))
|
|
}
|
|
sort.Float64s(times)
|
|
times = dedupe(times)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
defer close(e.done)
|
|
if err != nil {
|
|
e.status, e.err = "error", err.Error()
|
|
return
|
|
}
|
|
e.status, e.times = "done", times
|
|
}
|
|
|
|
func dedupe(sorted []float64) []float64 {
|
|
out := sorted[:0]
|
|
for i, t := range sorted {
|
|
if i == 0 || t-out[len(out)-1] > 1e-4 {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// proxyPath is where the browser-friendly preview copy of a source lives.
|
|
func (s *MediaStore) proxyPath(m *Media) string {
|
|
dir, err := os.UserCacheDir()
|
|
if err != nil {
|
|
dir = os.TempDir()
|
|
}
|
|
key := fmt.Sprintf("%s|%d|%d", m.Path, m.Size, m.ModTime.UnixNano())
|
|
h := sha1.Sum([]byte(key))
|
|
return filepath.Join(dir, "vibedit", "proxies", hex.EncodeToString(h[:10])+".mp4")
|
|
}
|
|
|
|
// handleProxy creates a small H.264 preview copy for sources the browser
|
|
// cannot play (AVI, WMV, unsupported codecs, ...).
|
|
func (a *App) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID string `json:"id"`
|
|
}
|
|
readJSON(r, &req)
|
|
m := a.media.Get(req.ID)
|
|
if m == nil {
|
|
writeError(w, http.StatusNotFound, "unknown source")
|
|
return
|
|
}
|
|
out := a.media.proxyPath(m)
|
|
if _, err := os.Stat(out); err == nil {
|
|
writeJSON(w, map[string]any{"ready": true})
|
|
return
|
|
}
|
|
if j := a.jobs.Find("proxy", m.ID); j != nil {
|
|
writeJSON(w, map[string]any{"jobId": j.ID})
|
|
return
|
|
}
|
|
if !a.tools.HasEncoder("libx264") && a.tools.EncodersReady() {
|
|
writeError(w, http.StatusInternalServerError, "this ffmpeg build has no H.264 encoder (libx264) for previews")
|
|
return
|
|
}
|
|
j := a.jobs.Start("proxy", "Preview of "+m.Name, m.ID, func(ctx context.Context, c *JobCtl) error {
|
|
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4"
|
|
args := ffmpegBaseArgs()
|
|
args = append(args, "-i", m.Path)
|
|
if m.Video != nil {
|
|
args = append(args, "-map", fmt.Sprintf("0:%d", m.Video.Index),
|
|
"-vf", "scale=-2:'2*trunc(min(540,ih)/2)',format=yuv420p",
|
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-g", "12", "-bf", "0")
|
|
}
|
|
if a := m.FirstAudio(); a != nil {
|
|
args = append(args, "-map", fmt.Sprintf("0:%d", a.Index), "-c:a", "aac", "-b:a", "128k", "-ac", "2")
|
|
}
|
|
args = append(args, "-sn", "-dn", "-map_chapters", "-1", "-movflags", "+faststart", tmp)
|
|
err := a.tools.runFFmpeg(ctx, args, func(sec float64, speed string) {
|
|
c.Progress(sec/m.Duration, speed)
|
|
})
|
|
if err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, out)
|
|
})
|
|
writeJSON(w, map[string]any{"jobId": j.ID})
|
|
}
|
|
|
|
// byteCache is a size-bounded FIFO cache for rendered thumbnails.
|
|
type byteCache struct {
|
|
mu sync.Mutex
|
|
max int
|
|
size int
|
|
order []string
|
|
items map[string][]byte
|
|
}
|
|
|
|
func newByteCache(max int) *byteCache { return &byteCache{max: max, items: map[string][]byte{}} }
|
|
|
|
func (c *byteCache) Get(k string) []byte {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.items[k]
|
|
}
|
|
|
|
func (c *byteCache) Put(k string, v []byte) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if _, ok := c.items[k]; ok {
|
|
return
|
|
}
|
|
c.items[k] = v
|
|
c.order = append(c.order, k)
|
|
c.size += len(v)
|
|
for c.size > c.max && len(c.order) > 0 {
|
|
old := c.order[0]
|
|
c.order = c.order[1:]
|
|
c.size -= len(c.items[old])
|
|
delete(c.items, old)
|
|
}
|
|
}
|