diff --git a/README.en.md b/README.en.md index 30bb723..c7dcc79 100644 --- a/README.en.md +++ b/README.en.md @@ -143,11 +143,11 @@ After downloading and extracting a new version, copy the current `data` director - Backend: Go + Gin + GORM + SQLite - Frontend: React + Vite + Tailwind + Zustand - Media probing: `ffprobe` -- Screenshot generation: `mpv` +- Screenshot generation: `ffmpeg` on macOS, `mpv` on other platforms ### Common Commands -Download dependencies (`ffprobe` + `mpv`): +Download dependencies (`ffprobe` + `mpv`, plus `ffmpeg` on macOS): ```bash ./scripts/cli.sh download linux-x86_64 diff --git a/README.md b/README.md index a9cb8a9..e05eeb9 100644 --- a/README.md +++ b/README.md @@ -166,11 +166,11 @@ porn manager, jav manager, av manager, jav scraper, jav metadata, adult video ma - Backend: Go + Gin + GORM + SQLite - Frontend: React + Vite + Tailwind + Zustand - 媒体探测: `ffprobe` -- 截图生成: `mpv` +- 截图生成: macOS 使用 `ffmpeg`,其他平台使用 `mpv` ### 常用命令 -下载依赖(`ffprobe` + `mpv`): +下载依赖(`ffprobe` + `mpv`,macOS 额外下载 `ffmpeg`): ```bash ./scripts/cli.sh download linux-x86_64 diff --git a/internal/manager/screenshot_manager.go b/internal/manager/screenshot_manager.go index 5477cb5..ffc5c76 100644 --- a/internal/manager/screenshot_manager.go +++ b/internal/manager/screenshot_manager.go @@ -242,30 +242,29 @@ func (m *ScreenshotManager) capture(ctx context.Context, videoPath string, secon return fmt.Errorf("ensure screenshot dir: %w", err) } - mpvPath, pathErr := util.ResolveMPVPath() - if pathErr != nil { - return fmt.Errorf("resolve mpv path: %w", pathErr) - } - tempDir, err := os.MkdirTemp(filepath.Dir(outputPath), ".mpv-shot-*") + tempDir, err := os.MkdirTemp(filepath.Dir(outputPath), ".screenshot-*") if err != nil { - return fmt.Errorf("create mpv temp dir: %w", err) + return fmt.Errorf("create screenshot temp dir: %w", err) } defer func() { _ = os.RemoveAll(tempDir) }() shotPath := filepath.Join(tempDir, "00000001.jpg") - args := []string{ - "--no-config", - "--really-quiet", - "--msg-level=all=error", - "--ao=null", - "--hr-seek=yes", - "--start=" + strconv.Itoa(second), - "--frames=1", - "--vo=image", - "--vo-image-format=jpg", - "--vo-image-outdir=" + tempDir, - videoPath, + if runtime.GOOS == "darwin" { + ffmpegPath, err := util.ResolveFFmpegPath() + if err != nil { + return fmt.Errorf("resolve ffmpeg path: %w", err) + } + if err := runFFmpegScreenshot(ctx, ffmpegPath, videoPath, second, shotPath); err != nil { + return err + } + return moveScreenshot(shotPath, outputPath) + } + + mpvPath, pathErr := util.ResolveMPVPath() + if pathErr != nil { + return fmt.Errorf("resolve mpv path: %w", pathErr) } + args := buildMPVScreenshotArgs(second, tempDir, videoPath) cmd := exec.CommandContext(ctx, mpvPath, args...) out, err := cmd.CombinedOutput() @@ -290,12 +289,71 @@ func (m *ScreenshotManager) capture(ctx context.Context, videoPath string, secon return errors.New("mpv produced empty screenshot file") } + return moveScreenshot(shotPath, outputPath) +} + +func runFFmpegScreenshot(ctx context.Context, ffmpegPath string, videoPath string, second int, outputPath string) error { + args := buildFFmpegScreenshotArgs(second, outputPath, videoPath) + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + out, err := cmd.CombinedOutput() + if err != nil { + _ = os.Remove(outputPath) + lastOut := strings.TrimSpace(string(out)) + if lastOut != "" { + return fmt.Errorf("ffmpeg screenshot failed: %w: %s", err, lastOut) + } + return fmt.Errorf("ffmpeg screenshot failed: %w", err) + } + + info, err := os.Stat(outputPath) + if err != nil { + return errors.New("ffmpeg produced no screenshot file") + } + if info.Size() == 0 { + _ = os.Remove(outputPath) + return errors.New("ffmpeg produced empty screenshot file") + } + return nil +} + +func moveScreenshot(shotPath string, outputPath string) error { if err := os.Rename(shotPath, outputPath); err != nil { return fmt.Errorf("rename screenshot: %w", err) } return nil } +func buildFFmpegScreenshotArgs(second int, outputPath string, videoPath string) []string { + return []string{ + "-nostdin", + "-hide_banner", + "-loglevel", "error", + "-y", + "-ss", strconv.Itoa(second), + "-i", videoPath, + "-map", "0:v:0", + "-frames:v", "1", + "-q:v", "2", + outputPath, + } +} + +func buildMPVScreenshotArgs(second int, tempDir string, videoPath string) []string { + return []string{ + "--no-config", + "--really-quiet", + "--msg-level=all=error", + "--ao=null", + "--hr-seek=yes", + "--start=" + strconv.Itoa(second), + "--frames=1", + "--vo=image", + "--vo-image-format=jpg", + "--vo-image-outdir=" + tempDir, + videoPath, + } +} + func resolveVideoPath(video *models.Video) (string, error) { if video == nil { return "", errors.New("video is nil") diff --git a/internal/manager/screenshot_manager_test.go b/internal/manager/screenshot_manager_test.go new file mode 100644 index 0000000..8fe363c --- /dev/null +++ b/internal/manager/screenshot_manager_test.go @@ -0,0 +1,54 @@ +package manager + +import ( + "slices" + "testing" +) + +func TestBuildFFmpegScreenshotArgsIncludesHeadlessImageOptions(t *testing.T) { + args := buildFFmpegScreenshotArgs(12, "/tmp/screenshots/00000001.jpg", "/videos/example.mp4") + + required := []string{ + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-ss", + "12", + "-i", + "/videos/example.mp4", + "-map", + "0:v:0", + "-frames:v", + "1", + "-q:v", + "2", + "/tmp/screenshots/00000001.jpg", + } + for _, option := range required { + if !slices.Contains(args, option) { + t.Fatalf("expected ffmpeg screenshot args to include %q, got %v", option, args) + } + } +} + +func TestBuildMPVScreenshotArgsIncludesImageOutputOptions(t *testing.T) { + args := buildMPVScreenshotArgs(12, "/tmp/screenshots", "/videos/example.mp4") + + required := []string{ + "--no-config", + "--ao=null", + "--start=12", + "--frames=1", + "--vo=image", + "--vo-image-format=jpg", + "--vo-image-outdir=/tmp/screenshots", + "/videos/example.mp4", + } + for _, option := range required { + if !slices.Contains(args, option) { + t.Fatalf("expected mpv screenshot args to include %q, got %v", option, args) + } + } +} diff --git a/internal/mpv/input.go b/internal/mpv/input.go index d4f87e9..69fb02f 100644 --- a/internal/mpv/input.go +++ b/internal/mpv/input.go @@ -329,23 +329,26 @@ func buildConfigContent() (string, error) { } lines := []string{ - "auto-window-resize=no", "keep-open=yes", fmt.Sprintf("ontop=%s", mpvBool(ontop)), } if useAutofit { + lines = append(lines, fmt.Sprintf("autofit=%d%%x%d%%", windowWidth, windowHeight)) + } else { lines = append(lines, - fmt.Sprintf("autofit=%d%%x%d%%", windowWidth, windowHeight), - "geometry=50%:50%", + "auto-window-resize=no", + "geometry="+centeredWindowGeometry(windowWidth, windowHeight), ) - } else { - lines = append(lines, fmt.Sprintf("geometry=%d%%x%d%%", windowWidth, windowHeight)) } lines = append(lines, fmt.Sprintf("volume=%d", volume)) return strings.Join(lines, "\n") + "\n", nil } +func centeredWindowGeometry(width, height int) string { + return fmt.Sprintf("%d%%x%d%%+50%%+50%%", width, height) +} + func loadConfiguredPlayerBaseSettings() (int, int, bool, int, bool, error) { if common.DB == nil { return defaultWindowWidth, defaultWindowHeight, false, defaultVolume, defaultOntop, nil diff --git a/internal/mpv/input_test.go b/internal/mpv/input_test.go index 6d263dd..ac0814a 100644 --- a/internal/mpv/input_test.go +++ b/internal/mpv/input_test.go @@ -24,9 +24,15 @@ func TestBuildConfigContentIncludesRequiredDefaults(t *testing.T) { if !strings.Contains(content, "keep-open=yes\n") { t.Fatalf("expected keep-open=yes in mpv config, got %q", content) } + if !strings.Contains(content, "auto-window-resize=no\n") { + t.Fatalf("expected auto-window-resize=no in fixed-size mpv config, got %q", content) + } if !strings.Contains(content, "ontop=yes\n") { t.Fatalf("expected ontop=yes in mpv config, got %q", content) } + if !strings.Contains(content, "geometry=70%x70%+50%+50%\n") { + t.Fatalf("expected centered default geometry in mpv config, got %q", content) + } } func TestBuildConfigContentRespectsConfiguredOntop(t *testing.T) { @@ -47,6 +53,49 @@ func TestBuildConfigContentRespectsConfiguredOntop(t *testing.T) { } } +func TestBuildConfigContentCentersConfiguredWindowSize(t *testing.T) { + openConfigTestDB(t) + if err := dbpkg.UpsertConfig(context.Background(), map[string]string{ + playerWindowWidthConfigKey: "80", + playerWindowHeightConfigKey: "60", + }); err != nil { + t.Fatalf("upsert config: %v", err) + } + + content, err := buildConfigContent() + if err != nil { + t.Fatalf("buildConfigContent returned error: %v", err) + } + + if !strings.Contains(content, "geometry=80%x60%+50%+50%\n") { + t.Fatalf("expected centered configured geometry in mpv config, got %q", content) + } +} + +func TestBuildConfigContentUsesOnlyAutofitForAutomaticWindowSize(t *testing.T) { + openConfigTestDB(t) + if err := dbpkg.UpsertConfig(context.Background(), map[string]string{ + playerWindowUseAutofitConfigKey: "true", + }); err != nil { + t.Fatalf("upsert config: %v", err) + } + + content, err := buildConfigContent() + if err != nil { + t.Fatalf("buildConfigContent returned error: %v", err) + } + + if !strings.Contains(content, "autofit=70%x70%\n") { + t.Fatalf("expected default autofit size in mpv config, got %q", content) + } + if strings.Contains(content, "auto-window-resize=no\n") { + t.Fatalf("expected autofit mpv config to leave automatic window resize enabled, got %q", content) + } + if strings.Contains(content, "geometry=") { + t.Fatalf("expected autofit mpv config to omit fixed geometry, got %q", content) + } +} + func openConfigTestDB(t *testing.T) { t.Helper() diff --git a/internal/util/video.go b/internal/util/video.go index 6e508e1..b0ec4a0 100644 --- a/internal/util/video.go +++ b/internal/util/video.go @@ -121,6 +121,10 @@ var ( ffprobeOnce sync.Once ffprobePath string ffprobeErr error + + ffmpegOnce sync.Once + ffmpegPath string + ffmpegErr error ) // ResolveFFprobePath resolves the ffprobe binary location. @@ -131,15 +135,31 @@ func ResolveFFprobePath() (string, error) { return ffprobePath, ffprobeErr } +// ResolveFFmpegPath resolves the ffmpeg binary location. +func ResolveFFmpegPath() (string, error) { + ffmpegOnce.Do(func() { + ffmpegPath, ffmpegErr = findFFmpegPath() + }) + return ffmpegPath, ffmpegErr +} + func findFFprobePath() (string, error) { + return findFFBinaryPath("FFPROBE_PATH", "ffprobe") +} + +func findFFmpegPath() (string, error) { + return findFFBinaryPath("FFMPEG_PATH", "ffmpeg") +} + +func findFFBinaryPath(envKey, name string) (string, error) { var candidates []string - if env := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); env != "" { + if env := strings.TrimSpace(os.Getenv(envKey)); env != "" { candidates = append(candidates, env) } - binName := "ffprobe" + binName := name if runtime.GOOS == "windows" { - binName = "ffprobe.exe" + binName = name + ".exe" } if wd, err := os.Getwd(); err == nil { @@ -159,7 +179,7 @@ func findFFprobePath() (string, error) { return resolved, nil } } - return "", errors.New("ffprobe not found; set FFPROBE_PATH or place binary at internal/bin/ffprobe") + return "", fmt.Errorf("%s not found; set %s or place binary at internal/bin/%s", name, envKey, binName) } // ProbeVideo extracts codec/resolution/fps/duration using ffprobe. diff --git a/scripts/cli/cli.mjs b/scripts/cli/cli.mjs index a5f1dcb..a9673a1 100755 --- a/scripts/cli/cli.mjs +++ b/scripts/cli/cli.mjs @@ -43,7 +43,7 @@ const PLATFORM_CHOICES = [ ]; const PLATFORM_BY_LABEL = new Map(PLATFORM_CHOICES.map((p) => [p.label, p])); -const FFPROBE_DOWNLOADS = new Map([ +const FF_BINARY_DOWNLOADS = new Map([ [ "windows-x86_64", { @@ -59,12 +59,14 @@ const FFPROBE_DOWNLOADS = new Map([ [ "macos-x86_64", { + ffmpeg: "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-darwin-x64.gz", ffprobe: "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffprobe-darwin-x64.gz", }, ], [ "macos-arm64", { + ffmpeg: "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-darwin-arm64.gz", ffprobe: "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffprobe-darwin-arm64.gz", }, ], @@ -125,10 +127,18 @@ function ffprobeBinName(goos) { return goos === "windows" ? "ffprobe.exe" : "ffprobe"; } +function ffmpegBinName(goos) { + return goos === "windows" ? "ffmpeg.exe" : "ffmpeg"; +} + function ffprobePath(choice) { return path.join(INTERNAL_BIN_DIR, ffprobeBinName(choice.goos)); } +function ffmpegPath(choice) { + return path.join(INTERNAL_BIN_DIR, ffmpegBinName(choice.goos)); +} + function internalMpvDir() { return path.join(INTERNAL_BIN_DIR, "mpv"); } @@ -141,6 +151,10 @@ function binFfprobePath(choice) { return path.join(platformBinDir(choice), ffprobeBinName(choice.goos)); } +function binFfmpegPath(choice) { + return path.join(platformBinDir(choice), ffmpegBinName(choice.goos)); +} + function binMpvDir(choice) { return path.join(platformBinDir(choice), "mpv"); } @@ -185,6 +199,10 @@ async function isBundledFfprobeReady(choice) { return exists(binFfprobePath(choice)); } +async function isBundledFfmpegReady(choice) { + return exists(binFfmpegPath(choice)); +} + async function isBundledMpvReady(choice) { return exists(binMpvPath(choice)); } @@ -274,6 +292,26 @@ async function startBackendDevChild() { return null; } + if (current.goos === "darwin") { + let ffmpegOk = await isExecutable(ffmpegPath(current)); + if (!ffmpegOk) { + if (await isBundledFfmpegReady(current)) { + const binFfmpeg = binFfmpegPath(current); + await fsp.mkdir(INTERNAL_BIN_DIR, { recursive: true }); + await fsp.copyFile(binFfmpeg, ffmpegPath(current)); + await fsp.chmod(ffmpegPath(current), 0o755); + ffmpegOk = true; + } + } + if (!ffmpegOk) { + console.error( + `[dev] internal/bin 缺少 ${current.label} 的 ffmpeg,请先选择 “download dependencies” 下载到 bin/${current.label}。`, + ); + process.exitCode = 1; + return null; + } + } + await syncBundledMpvToInternal(current); const addr = process.env.ADDR || ":17654"; @@ -359,6 +397,18 @@ async function copyBundledFfprobe(choice, outDir) { } } +async function copyBundledFfmpeg(choice, outDir) { + const srcFfmpeg = binFfmpegPath(choice); + const destDir = path.join(outDir, "internal", "bin"); + const destFfmpeg = path.join(destDir, ffmpegBinName(choice.goos)); + + await fsp.mkdir(destDir, { recursive: true }); + await fsp.copyFile(srcFfmpeg, destFfmpeg); + if (choice.goos !== "windows") { + await fsp.chmod(destFfmpeg, 0o755); + } +} + async function copyBundledMpv(choice, outDir) { const destDir = path.join(outDir, "internal", "bin", "mpv"); await copyDir(binMpvDir(choice), destDir); @@ -414,6 +464,14 @@ async function runRelease(choice, version) { process.exitCode = 1; return; } + const ffmpegOk = choice.goos !== "darwin" || (await exists(binFfmpegPath(choice))); + if (!ffmpegOk) { + console.error( + `[release] bin/${choice.label} 缺少 ffmpeg,请先选择 “download dependencies” 下载。`, + ); + process.exitCode = 1; + return; + } const bundledMpvOk = await isBundledMpvReady(choice); const requireBundledMpv = true; if (requireBundledMpv && !bundledMpvOk) { @@ -434,6 +492,10 @@ async function runRelease(choice, version) { await buildBackendRelease(choice, outDir); console.log("[release] 复制 ffprobe"); await copyBundledFfprobe(choice, outDir); + if (choice.goos === "darwin") { + console.log("[release] 复制 ffmpeg"); + await copyBundledFfmpeg(choice, outDir); + } if (bundledMpvOk) { console.log("[release] 复制 mpv"); await copyBundledMpv(choice, outDir); @@ -454,12 +516,19 @@ async function runRelease(choice, version) { } function ffprobeUrls(choice) { - const linked = FFPROBE_DOWNLOADS.get(choice.label); + const linked = FF_BINARY_DOWNLOADS.get(choice.label); return { urls: linked?.ffprobe ? [linked.ffprobe] : [], }; } +function ffmpegUrls(choice) { + const linked = FF_BINARY_DOWNLOADS.get(choice.label); + return { + urls: linked?.ffmpeg ? [linked.ffmpeg] : [], + }; +} + function mpvUrls(choice) { const osUpper = choice.goos.toUpperCase(); const archUpper = choice.goarch.toUpperCase(); @@ -714,6 +783,56 @@ async function downloadFfprobe(choice) { } } +async function downloadFfmpeg(choice) { + const ffmpegTarget = binFfmpegPath(choice); + + if (await isBundledFfmpegReady(choice)) { + console.log(`[ffmpeg] 已存在:${platformBinDir(choice)}`); + return; + } + + const { urls } = ffmpegUrls(choice); + if (!urls.length) { + throw new Error(`[ffmpeg] 未找到下载地址(${choice.label})`); + } + + await fsp.mkdir(platformBinDir(choice), { recursive: true }); + const tmpBase = await fsp.mkdtemp(path.join(os.tmpdir(), "pornboss-ffmpeg-")); + try { + let installed = false; + for (const url of urls) { + console.log(`[ffmpeg] 下载 ${choice.label}:${url}`); + installed = await installBinaryFromUrl({ + url, + target: ffmpegTarget, + binaryName: ffmpegBinName(choice.goos), + logLabel: "ffmpeg", + choice, + tmpBase, + }); + if (installed) { + console.log(`[ffmpeg] 安装完成:${platformBinDir(choice)}`); + break; + } + } + + if (!installed) { + throw new Error(`[ffmpeg] 下载失败,请检查脚本内置的 ${choice.label} 下载链接`); + } + + const current = currentPlatformChoice(); + if (current && current.label === choice.label) { + await fsp.mkdir(INTERNAL_BIN_DIR, { recursive: true }); + await fsp.copyFile(ffmpegTarget, ffmpegPath(choice)); + if (choice.goos !== "windows") { + await fsp.chmod(ffmpegPath(choice), 0o755); + } + } + } finally { + await fsp.rm(tmpBase, { recursive: true, force: true }); + } +} + async function downloadMpv(choice) { if (await isBundledMpvReady(choice)) { console.log(`[mpv] 已存在:${binMpvDir(choice)}`); @@ -816,6 +935,9 @@ async function downloadMpv(choice) { async function downloadDependencies(choice) { await downloadFfprobe(choice); + if (choice.goos === "darwin") { + await downloadFfmpeg(choice); + } await downloadMpv(choice); }