Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 02:28:47 +00:00
commit c6ac7d2222
21 changed files with 5386 additions and 0 deletions

566
media.go Normal file
View File

@@ -0,0 +1,566 @@
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)
}
}