package preview import ( "bytes" "context" "crypto/sha1" "encoding/hex" "fmt" "net" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" "convert/internal/media" ) type Server struct { addr string httpSrv *http.Server ctx context.Context cancel context.CancelFunc ffmpegBin string mu sync.RWMutex files map[string]registeredFile proxyLocks map[string]*sync.Mutex proxyCache map[string]string thumbCache map[string][]byte tempFiles []string } type registeredFile struct { info *media.VideoInfo } func Start(ffmpegBin string) (*Server, error) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) s := &Server{ addr: ln.Addr().String(), ctx: ctx, cancel: cancel, ffmpegBin: ffmpegBin, files: make(map[string]registeredFile), proxyLocks: make(map[string]*sync.Mutex), proxyCache: make(map[string]string), thumbCache: make(map[string][]byte), } mux := http.NewServeMux() mux.HandleFunc("/video", s.handleVideo) mux.HandleFunc("/proxy", s.handleProxy) mux.HandleFunc("/thumb", s.handleThumb) s.httpSrv = &http.Server{ Handler: mux, ReadHeaderTimeout: 10 * time.Second, } go func() { _ = s.httpSrv.Serve(ln) }() return s, nil } func (s *Server) SetFFmpeg(bin string) { s.mu.Lock() defer s.mu.Unlock() s.ffmpegBin = bin } func (s *Server) BaseURL() string { return "http://" + s.addr } func (s *Server) Register(info *media.VideoInfo) string { var modUnix int64 if st, err := os.Stat(info.Path); err == nil { modUnix = st.ModTime().UnixNano() } key := fmt.Sprintf("%s|%d|%d", info.Path, info.SizeBytes, modUnix) sum := sha1.Sum([]byte(key)) id := hex.EncodeToString(sum[:8]) s.mu.Lock() s.files[id] = registeredFile{info: info} s.mu.Unlock() return id } func (s *Server) Close() { if s.cancel != nil { s.cancel() } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if s.httpSrv != nil { _ = s.httpSrv.Shutdown(ctx) } s.mu.Lock() for _, p := range s.tempFiles { _ = os.Remove(p) } s.tempFiles = nil s.mu.Unlock() } func (s *Server) getFile(id string) (registeredFile, bool) { s.mu.RLock() defer s.mu.RUnlock() rf, ok := s.files[id] return rf, ok } func (s *Server) handleVideo(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") rf, ok := s.getFile(id) if !ok { http.NotFound(w, r) return } f, err := os.Open(rf.info.Path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() st, err := f.Stat() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Access-Control-Allow-Origin", "*") http.ServeContent(w, r, rf.info.FileName, st.ModTime(), f) } func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") merge := r.URL.Query().Get("merge") == "1" || r.URL.Query().Get("merge") == "true" rf, ok := s.getFile(id) if !ok { http.NotFound(w, r) return } s.mu.RLock() ffmpegBin := s.ffmpegBin s.mu.RUnlock() if ffmpegBin == "" { http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable) return } cacheKey := fmt.Sprintf("%s_m%v", id, merge) s.mu.Lock() lk, exists := s.proxyLocks[cacheKey] if !exists { lk = &sync.Mutex{} s.proxyLocks[cacheKey] = lk } s.mu.Unlock() lk.Lock() defer lk.Unlock() s.mu.RLock() proxyPath := s.proxyCache[cacheKey] s.mu.RUnlock() if proxyPath == "" { proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey)) if err := s.buildProxy(s.ctx, ffmpegBin, rf.info, merge, proxyPath); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.mu.Lock() s.proxyCache[cacheKey] = proxyPath s.tempFiles = append(s.tempFiles, proxyPath) s.mu.Unlock() } f, err := os.Open(proxyPath) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() st, err := f.Stat() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Access-Control-Allow-Origin", "*") http.ServeContent(w, r, "preview.mp4", st.ModTime(), f) } func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.VideoInfo, merge bool, outPath string) error { args := []string{"-hide_banner", "-loglevel", "error", "-y", "-i", info.Path} numAudio := len(info.AudioStreams) if numAudio == 0 { args = append(args, "-map", "0:v:0", "-an") } else if merge && numAudio > 1 { var in strings.Builder for i := 0; i < numAudio; i++ { in.WriteString(fmt.Sprintf("[0:a:%d]", i)) } args = append(args, "-filter_complex", fmt.Sprintf("%samix=inputs=%d:normalize=0[a]", in.String(), numAudio), "-map", "0:v:0", "-map", "[a]", "-c:a", "aac", "-b:a", "128k", ) } else { args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k") } if info.VideoCodec == "h264" && media.IsH264BrowserNativePixFmt(info.PixFmt) { // Remux video stream instantaneously when codec is already browser-native 8-bit H.264 args = append(args, "-c:v", "copy") } else { args = append(args, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26", "-pix_fmt", "yuv420p", "-vf", "scale='min(1280,trunc(iw/2)*2)':-2", ) } args = append(args, "-movflags", "+faststart", outPath) cmd := exec.CommandContext(ctx, ffmpegBin, args...) media.HideCmdWindow(cmd) var errBuf bytes.Buffer cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { _ = os.Remove(outPath) return fmt.Errorf("preview proxy error: %v (%s)", err, strings.TrimSpace(errBuf.String())) } return nil } func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") tStr := r.URL.Query().Get("t") rf, ok := s.getFile(id) if !ok { http.NotFound(w, r) return } tSec, err := strconv.ParseFloat(tStr, 64) if err != nil || tSec < 0 { tSec = 0 } if rf.info.Duration > 0 && tSec >= rf.info.Duration { tSec = rf.info.Duration - 0.1 if tSec < 0 { tSec = 0 } } key := fmt.Sprintf("%s_%.2f", id, tSec) s.mu.RLock() cached, found := s.thumbCache[key] ffmpegBin := s.ffmpegBin s.mu.RUnlock() if !found { if ffmpegBin == "" { http.Error(w, "ffmpeg not configured", http.StatusServiceUnavailable) return } cmd := exec.CommandContext(r.Context(), ffmpegBin, "-hide_banner", "-loglevel", "error", "-ss", strconv.FormatFloat(tSec, 'f', 3, 64), "-i", rf.info.Path, "-frames:v", "1", "-vf", "scale=200:-1", "-q:v", "6", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", ) media.HideCmdWindow(cmd) data, err := cmd.Output() if err != nil || len(data) == 0 { http.Error(w, "thumbnail generation failed", http.StatusInternalServerError) return } s.mu.Lock() if len(s.thumbCache) > 200 { // simple cap reset s.thumbCache = make(map[string][]byte) } s.thumbCache[key] = data s.mu.Unlock() cached = data } w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Cache-Control", "public, max-age=3600") w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = w.Write(cached) }