diff --git a/internal/service/adult_image_crop.go b/internal/service/adult_image_crop.go index 825d550..411f99d 100644 --- a/internal/service/adult_image_crop.go +++ b/internal/service/adult_image_crop.go @@ -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() diff --git a/internal/service/adult_image_crop_test.go b/internal/service/adult_image_crop_test.go index 9da1bf8..4eead39 100644 --- a/internal/service/adult_image_crop_test.go +++ b/internal/service/adult_image_crop_test.go @@ -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) + } + } } } diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index 5b335ee..3d63f5e 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -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/.. 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/.. 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 } diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go index ba9cfc9..9cf945b 100644 --- a/internal/service/scraper_write_metadata_files_test.go +++ b/internal/service/scraper_write_metadata_files_test.go @@ -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) }