Merge changes from prometheus/mebox-40a7378b

This commit is contained in:
truewhile
2026-11-12 22:13:57 +08:00
parent d7c150ffd9
commit 5806839601
4 changed files with 122 additions and 16 deletions
+46 -5
View File
@@ -8,6 +8,7 @@ import (
_ "image/png" // register PNG decoder
"strings"
"github.com/metatube-community/metatube-sdk-go/common/number"
"github.com/metatube-community/metatube-sdk-go/detector"
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
)
@@ -46,11 +47,27 @@ func IsAdultArtworkURL(raw string) bool {
strings.Contains(lower, "pics.dmm.co.jp")
}
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
// If so, it uses MetaTube's Pigo face detector to position a 2:3 poster crop.
// When no face is detected, it falls back to the conventional right-side front cover.
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15)
// and crops the conventional right-side front cover out of it. It is the code-agnostic entry point;
// prefer CropAdultCoverPosterForCode when the movie number is known.
// If the image is already portrait or cannot be decoded, it safely returns the original data.
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return CropAdultCoverPosterForCode(data, "")
}
// CropAdultCoverPosterForCode crops a 2:3 poster out of a wide full-jacket DVD cover.
//
// Layout matters: a regular DVD jacket is [封底 | 书脊 | 封面] unfolded right-to-left, so the
// front cover is always the right-most panel — exactly what upstream MetaTube does with its
// default position 1.0. Face detection is only meaningful for "special" numbers
// (FC2 / HEYZO / 素人 / 无码) whose cover is a snapshot whose composition varies
// (see metatube-sdk-go/common/number.RequiresFaceDetection).
//
// Running the detector on a regular jacket is actively harmful: the back cover of those jackets is a
// montage of sample stills that is packed with faces, so the dominant face cluster lands on the montage
// (or right on the spine), and a crop centred there straddles the seam giving "half back cover + half
// front cover" posters.
func CropAdultCoverPosterForCode(data []byte, code string) ([]byte, string, error) {
if len(data) == 0 {
return data, "", nil
}
@@ -70,8 +87,10 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
}
position := 1.0
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = detected
if adultCoverNeedsFaceDetection(code) {
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = snapAdultCoverPosition(detected)
}
}
cropped := cropImageAtPosition(src, adultPosterRatio, position)
@@ -82,6 +101,28 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return buf.Bytes(), "image/jpeg", nil
}
// adultCoverNeedsFaceDetection reports whether the cover composition has to be probed with the face
// detector, mirroring upstream MetaTube: only "special" numbers (FC2 / HEYZO / 素人 / 无码) qualify,
// regular DVD numbers are always cropped at the right-most panel.
func adultCoverNeedsFaceDetection(code string) bool {
code = strings.TrimSpace(code)
if code == "" {
return false
}
return number.RequiresFaceDetection(code)
}
// snapAdultCoverPosition quantises a detected face axis onto a whole panel. A wide jacket only has two
// legal framings — the left panel or the right panel — so the crop window must never be centred on the
// detected axis itself: when that axis sits on/near the spine the result is a poster showing the right
// half of the back cover plus the left sliver of the front cover.
func snapAdultCoverPosition(position float64) float64 {
if position >= 0.5 {
return 1.0 // right panel: flush with the right edge
}
return 0.0 // left panel: flush with the left edge
}
func cropImageAtPosition(src image.Image, ratio, position float64) image.Image {
bounds := src.Bounds()
width := bounds.Dx()
+62 -3
View File
@@ -80,7 +80,7 @@ func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
}
}
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
func TestCropAdultCoverPosterUsesDetectedPanelForSpecialNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.25, true
@@ -90,7 +90,8 @@ func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
// 素人/FC2 这类番号构图不固定,允许人脸检测决定取哪一侧面板。
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "FC2-PPV-123456")
if err != nil {
t.Fatal(err)
}
@@ -100,7 +101,65 @@ func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if r <= b {
t.Fatalf("face-positioned crop did not follow detected left-side axis: r=%d b=%d", r, b)
t.Fatalf("special-number crop did not follow detected left-side axis: r=%d b=%d", r, b)
}
}
// 常规 DVD 番号的封套是 [封底 | 书脊 | 封面] 横图,封底那半张是样本剧照拼贴、充满人脸。
// 做人脸检测会让主簇落在拼贴或书脊上,裁出「半张封底 + 半张封面」。
// 这里模拟检测器给出跨缝的 0.42,要求常规番号完全忽略检测结果,只裁最右侧封面面板。
func TestCropAdultCoverPosterIgnoresFaceDetectionForStandardNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.42, true
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "SSNI-677")
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if b <= r {
t.Fatalf("standard-number crop should be the right-most panel (blue), got r=%d b=%d", r, b)
}
}
// 即使需要人脸定位,裁切框也必须吸附到整块面板,绝不能跨在书脊上。
func TestCropAdultCoverPosterSnapsToPanelForSpecialNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.42, true // 介于两个面板之间:落在书脊上
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "HEYZO-1234")
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
bounds := croppedImg.Bounds()
// 左侧面板 = 纯红;任何偏蓝像素都意味着裁切框越过了中线。
for y := bounds.Min.Y; y < bounds.Max.Y; y += 8 {
for x := bounds.Min.X; x < bounds.Max.X; x += 8 {
r, _, b, _ := croppedImg.At(x, y).RGBA()
if b > r {
t.Fatalf("crop straddles the panel seam at (%d,%d): r=%d b=%d", x, y, r, b)
}
}
}
}
@@ -46,26 +46,31 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
return
}
isAdult := shouldCropAdultPoster(refreshed, lib)
// 番号决定封套的裁剪方式:常规 DVD 取最右侧封面面板,FC2 / HEYZO / 素人等才做人脸定位。
adultCode := ""
if isAdult {
adultCode = AdultCodeFromMediaPath(refreshed.Path)
}
artworkUpdates := map[string]any{}
shouldPersistLocalArtworkURL := func(raw string) bool {
return isAdult || !isHTTPish(raw)
}
if refreshed.PosterURL != "" {
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult, adultCode); dst != "" {
if shouldPersistLocalArtworkURL(refreshed.PosterURL) {
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
}
}
if refreshed.BackdropURL != "" {
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false, ""); dst != "" {
if shouldPersistLocalArtworkURL(refreshed.BackdropURL) {
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
}
} else if isAdult && refreshed.PosterURL != "" {
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false, ""); dst != "" {
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
}
}
@@ -78,7 +83,7 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
}
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false, "")
}
// shouldCropAdultPoster keeps adult-cover handling independent of which
@@ -97,8 +102,9 @@ func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
}
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
// writes it under dir/<name>.<ext>. For adult posters it crops the front-cover panel out of the
// full-jacket cover (adultCode selects between “right-most panel” and face-aware framing).
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool, adultCode string) string {
var (
data []byte
ctype string
@@ -132,7 +138,7 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
ctype = detected
}
if cropAdultPoster {
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
if cropped, croppedType, err := CropAdultCoverPosterForCode(data, adultCode); err == nil && len(cropped) > 0 {
data = cropped
ctype = croppedType
}
@@ -155,7 +155,7 @@ func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
t.Fatal(err)
}
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true, "SSNI-677")
if filepath.Base(dst) != "movie-poster.jpg" {
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
}