ResolveWithYTDLP 改善: - 新增 friendlyYTDLPError() 把 yt-dlp 的技術性 stderr 訊息轉成繁中提示 - 涵蓋 9 種常見失敗原因: 年齡限制 / 私人影片 / 已移除 / 版權 / 直播 / 首播 / DRM / 頻率限制 / 無格式 - 錯誤訊息同時附上原始 stderr(方便 debug) - yt-dlp 未安裝時改用繁中提示 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
8.5 KiB
Go
304 lines
8.5 KiB
Go
package camera
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
)
|
||
|
||
// VideoInfo holds metadata extracted by ffprobe before pipeline starts.
|
||
type VideoInfo struct {
|
||
DurationSec float64 // total duration in seconds
|
||
TotalFrames int // estimated total frames at target FPS
|
||
}
|
||
|
||
// ProbeVideoInfo runs ffprobe to extract duration from a video file or URL.
|
||
// Returns zero values (no error) when duration is indeterminate (e.g. live streams).
|
||
func ProbeVideoInfo(input string, fps float64) VideoInfo {
|
||
cmd := exec.Command("ffprobe",
|
||
"-v", "error",
|
||
"-select_streams", "v:0",
|
||
"-show_entries", "format=duration",
|
||
"-of", "csv=p=0",
|
||
input,
|
||
)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return VideoInfo{}
|
||
}
|
||
durStr := strings.TrimSpace(string(out))
|
||
if durStr == "" || durStr == "N/A" {
|
||
return VideoInfo{}
|
||
}
|
||
dur, err := strconv.ParseFloat(durStr, 64)
|
||
if err != nil {
|
||
return VideoInfo{}
|
||
}
|
||
if fps <= 0 {
|
||
fps = 15
|
||
}
|
||
return VideoInfo{
|
||
DurationSec: dur,
|
||
TotalFrames: int(dur * fps),
|
||
}
|
||
}
|
||
|
||
// VideoSource reads a video file or URL frame-by-frame using ffmpeg, outputting
|
||
// JPEG frames via stdout. Reuses the same JPEG SOI/EOI marker parsing
|
||
// pattern as FFmpegCamera.
|
||
type VideoSource struct {
|
||
cmd *exec.Cmd
|
||
stdout io.ReadCloser
|
||
frameCh chan []byte // decoded frames queue
|
||
mu sync.Mutex
|
||
done chan struct{}
|
||
finished bool
|
||
err error
|
||
filePath string // local file path (empty for URL sources)
|
||
isURL bool // true when source is a URL, skip file cleanup
|
||
totalFrames int64 // 0 means unknown
|
||
frameCount int64 // atomic counter incremented in readLoop
|
||
}
|
||
|
||
// NewVideoSource starts an ffmpeg process that decodes a video file
|
||
// and outputs MJPEG frames to stdout at the specified FPS.
|
||
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
|
||
return newVideoSource(filePath, fps, false, 0)
|
||
}
|
||
|
||
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
|
||
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
||
return newVideoSource(filePath, fps, false, seekSeconds)
|
||
}
|
||
|
||
// NewVideoSourceFromURL starts an ffmpeg process that reads from a URL
|
||
// (HTTP, HTTPS, RTSP, etc.) and outputs MJPEG frames to stdout.
|
||
func NewVideoSourceFromURL(rawURL string, fps float64) (*VideoSource, error) {
|
||
return newVideoSource(rawURL, fps, true, 0)
|
||
}
|
||
|
||
// NewVideoSourceFromURLWithSeek starts ffmpeg from a URL at a specific position.
|
||
func NewVideoSourceFromURLWithSeek(rawURL string, fps float64, seekSeconds float64) (*VideoSource, error) {
|
||
return newVideoSource(rawURL, fps, true, seekSeconds)
|
||
}
|
||
|
||
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
|
||
// from platforms like YouTube, Vimeo, etc.
|
||
// Returns the resolved direct URL or an error.
|
||
func ResolveWithYTDLP(rawURL string) (string, error) {
|
||
cmd := exec.Command("yt-dlp", "-f", "best[ext=mp4]/best", "--get-url", rawURL)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||
return "", fmt.Errorf("%s\n%s", friendlyYTDLPError(stderr), stderr)
|
||
}
|
||
return "", fmt.Errorf("yt-dlp 未安裝或無法執行: %w", err)
|
||
}
|
||
|
||
resolved := strings.TrimSpace(string(out))
|
||
if resolved == "" {
|
||
return "", fmt.Errorf("yt-dlp 無法取得影片下載連結(影片可能有地區限制或需要登入)")
|
||
}
|
||
if idx := strings.Index(resolved, "\n"); idx > 0 {
|
||
resolved = resolved[:idx]
|
||
}
|
||
return resolved, nil
|
||
}
|
||
|
||
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
|
||
func friendlyYTDLPError(stderr string) string {
|
||
s := strings.ToLower(stderr)
|
||
switch {
|
||
case strings.Contains(s, "sign in") || strings.Contains(s, "age"):
|
||
return "此影片需要登入 YouTube 帳號才能觀看(例如年齡限制),無法直接使用"
|
||
case strings.Contains(s, "private"):
|
||
return "此影片為私人影片,無法存取"
|
||
case strings.Contains(s, "unavailable") || strings.Contains(s, "not available"):
|
||
return "此影片無法取得(可能已被移除或在你的地區不可用)"
|
||
case strings.Contains(s, "copyright"):
|
||
return "此影片因版權限制無法下載"
|
||
case strings.Contains(s, "live"):
|
||
return "此影片為直播串流,目前不支援直播推論"
|
||
case strings.Contains(s, "premiere"):
|
||
return "此影片為首播(Premiere),尚未開始或格式不支援"
|
||
case strings.Contains(s, "drm") || strings.Contains(s, "protected"):
|
||
return "此影片有 DRM 保護,無法下載"
|
||
case strings.Contains(s, "rate limit") || strings.Contains(s, "429"):
|
||
return "YouTube 暫時限制了請求頻率,請稍後再試"
|
||
case strings.Contains(s, "no video formats"):
|
||
return "找不到可用的影片格式"
|
||
default:
|
||
return "無法解析此影片連結,請確認 URL 是否正確且影片為公開狀態"
|
||
}
|
||
}
|
||
|
||
func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64) (*VideoSource, error) {
|
||
if fps <= 0 {
|
||
fps = 15
|
||
}
|
||
|
||
args := []string{}
|
||
if seekSeconds > 0 {
|
||
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
|
||
}
|
||
args = append(args,
|
||
"-i", input,
|
||
"-vf", fmt.Sprintf("fps=%g", fps),
|
||
"-f", "image2pipe",
|
||
"-vcodec", "mjpeg",
|
||
"-q:v", "5",
|
||
"-an",
|
||
"-",
|
||
)
|
||
|
||
cmd := exec.Command("ffmpeg", args...)
|
||
stdout, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
||
}
|
||
cmd.Stderr = nil
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
||
}
|
||
|
||
filePath := ""
|
||
if !isURL {
|
||
filePath = input
|
||
}
|
||
|
||
vs := &VideoSource{
|
||
cmd: cmd,
|
||
stdout: stdout,
|
||
frameCh: make(chan []byte, 30), // buffer up to 30 frames
|
||
done: make(chan struct{}),
|
||
filePath: filePath,
|
||
isURL: isURL,
|
||
}
|
||
|
||
go vs.readLoop()
|
||
return vs, nil
|
||
}
|
||
|
||
// readLoop scans ffmpeg stdout for JPEG SOI/EOI markers to extract frames.
|
||
func (v *VideoSource) readLoop() {
|
||
defer close(v.done)
|
||
defer close(v.frameCh)
|
||
|
||
reader := bufio.NewReaderSize(v.stdout, 1024*1024)
|
||
buf := make([]byte, 0, 512*1024)
|
||
inFrame := false
|
||
|
||
for {
|
||
b, err := reader.ReadByte()
|
||
if err != nil {
|
||
v.mu.Lock()
|
||
v.finished = true
|
||
if err != io.EOF {
|
||
v.err = fmt.Errorf("ffmpeg stream ended: %w", err)
|
||
}
|
||
v.mu.Unlock()
|
||
return
|
||
}
|
||
|
||
if !inFrame {
|
||
if b == 0xFF {
|
||
next, err := reader.ReadByte()
|
||
if err != nil {
|
||
v.mu.Lock()
|
||
v.finished = true
|
||
v.mu.Unlock()
|
||
return
|
||
}
|
||
if next == 0xD8 {
|
||
buf = buf[:0]
|
||
buf = append(buf, 0xFF, 0xD8)
|
||
inFrame = true
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
buf = append(buf, b)
|
||
|
||
if b == 0xD9 && len(buf) >= 2 && buf[len(buf)-2] == 0xFF {
|
||
frame := make([]byte, len(buf))
|
||
copy(frame, buf)
|
||
|
||
v.frameCh <- frame // blocks if buffer full, applies backpressure
|
||
atomic.AddInt64(&v.frameCount, 1)
|
||
inFrame = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// ReadFrame returns the next decoded frame, blocking until one is available.
|
||
// Returns an error when all frames have been consumed and ffmpeg has finished.
|
||
func (v *VideoSource) ReadFrame() ([]byte, error) {
|
||
frame, ok := <-v.frameCh
|
||
if !ok {
|
||
return nil, fmt.Errorf("video playback complete")
|
||
}
|
||
return frame, nil
|
||
}
|
||
|
||
// SetTotalFrames sets the expected total frame count (from ffprobe).
|
||
func (v *VideoSource) SetTotalFrames(n int) {
|
||
atomic.StoreInt64(&v.totalFrames, int64(n))
|
||
}
|
||
|
||
// TotalFrames returns the expected total frame count, or 0 if unknown.
|
||
func (v *VideoSource) TotalFrames() int {
|
||
return int(atomic.LoadInt64(&v.totalFrames))
|
||
}
|
||
|
||
// FrameCount returns the number of frames decoded so far.
|
||
func (v *VideoSource) FrameCount() int {
|
||
return int(atomic.LoadInt64(&v.frameCount))
|
||
}
|
||
|
||
// IsFinished returns true when the video file has been fully decoded
|
||
// AND all buffered frames have been consumed.
|
||
func (v *VideoSource) IsFinished() bool {
|
||
v.mu.Lock()
|
||
finished := v.finished
|
||
v.mu.Unlock()
|
||
return finished && len(v.frameCh) == 0
|
||
}
|
||
|
||
// CloseWithoutRemove stops the ffmpeg process but does NOT delete the temp file.
|
||
// Used when seeking: we need to restart ffmpeg from a different position but keep the file.
|
||
func (v *VideoSource) CloseWithoutRemove() error {
|
||
if v.cmd != nil && v.cmd.Process != nil {
|
||
_ = v.cmd.Process.Kill()
|
||
_ = v.cmd.Wait()
|
||
}
|
||
for range v.frameCh {
|
||
}
|
||
<-v.done
|
||
return nil
|
||
}
|
||
|
||
func (v *VideoSource) Close() error {
|
||
if v.cmd != nil && v.cmd.Process != nil {
|
||
_ = v.cmd.Process.Kill()
|
||
_ = v.cmd.Wait()
|
||
}
|
||
// Drain any remaining frames so readLoop can exit
|
||
for range v.frameCh {
|
||
}
|
||
<-v.done
|
||
// Only remove temp files, not URL sources
|
||
if !v.isURL && v.filePath != "" {
|
||
_ = os.Remove(v.filePath)
|
||
}
|
||
return nil
|
||
}
|