From 30d873462fd42c7fc940fe96ca68dfe396f350c7 Mon Sep 17 00:00:00 2001 From: Taichi Maeda Date: Thu, 22 Jan 2026 16:04:46 +0900 Subject: [PATCH] image/jpeg: add support for non-standard chroma subsampling ratios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "flex mode" decoding for JPEG images with non-standard YCbCr subsampling ratios that do not match the predefined YCbCrSubsampleRatio values. This includes cases where: 1. Cb and Cr components have different sampling factors 2. The Y component does not have the maximum sampling factors Such images were previously rejected with "unsupported luma/chroma subsampling ratio" but should be valid according to the JPEG specification: https://www.w3.org/Graphics/JPEG/itu-t81.pdf Flex mode allocates a YCbCr444 backing buffer and manually expands pixels according to each component's sampling factors relative to the maximum. This approach mirrors the implementation in kovidgoyal/imaging. Fixes #2362 goos: darwin goarch: arm64 pkg: image/jpeg cpu: Apple M4 Max │ old.txt │ new.txt │ │ sec/op │ sec/op vs base │ FDCT-16 576.9n ± 1% 578.9n ± 1% ~ (p=0.565 n=10) IDCT-16 550.1n ± 0% 573.6n ± 3% +4.27% (p=0.000 n=10) DecodeBaseline-16 520.6µ ± 4% 523.8µ ± 2% ~ (p=0.796 n=10) DecodeProgressive-16 767.9µ ± 3% 747.0µ ± 10% ~ (p=0.123 n=10) EncodeRGBA-16 7.869m ± 3% 8.485m ± 6% +7.82% (p=0.001 n=10) EncodeYCbCr-16 8.761m ± 6% 8.021m ± 2% -8.45% (p=0.001 n=10) geomean 143.5µ 143.8µ +0.18% │ old.txt │ new.txt │ │ B/s │ B/s vs base │ DecodeBaseline-16 113.2Mi ± 4% 112.5Mi ± 2% ~ (p=0.796 n=10) DecodeProgressive-16 76.75Mi ± 3% 78.90Mi ± 10% ~ (p=0.123 n=10) EncodeRGBA-16 148.9Mi ± 3% 138.1Mi ± 7% -7.25% (p=0.001 n=10) EncodeYCbCr-16 100.3Mi ± 7% 109.6Mi ± 2% +9.23% (p=0.001 n=10) geomean 106.7Mi 107.7Mi +0.86% │ old.txt │ new.txt │ │ B/op │ B/op vs base │ DecodeBaseline-16 61.55Ki ± 0% 61.55Ki ± 0% ~ (p=1.000 n=10) ¹ DecodeProgressive-16 253.6Ki ± 0% 253.6Ki ± 0% ~ (p=0.124 n=10) EncodeRGBA-16 4.438Ki ± 0% 4.438Ki ± 0% ~ (p=1.000 n=10) ¹ EncodeYCbCr-16 4.438Ki ± 0% 4.438Ki ± 0% ~ (p=1.000 n=10) ¹ geomean 23.55Ki 23.55Ki +0.00% ¹ all samples are equal │ old.txt │ new.txt │ │ allocs/op │ allocs/op vs base │ DecodeBaseline-16 5.000 ± 0% 5.000 ± 0% ~ (p=1.000 n=10) ¹ DecodeProgressive-16 13.00 ± 0% 13.00 ± 0% ~ (p=1.000 n=10) ¹ EncodeRGBA-16 7.000 ± 0% 7.000 ± 0% ~ (p=1.000 n=10) ¹ EncodeYCbCr-16 7.000 ± 0% 7.000 ± 0% ~ (p=1.000 n=10) ¹ geomean 7.512 7.512 +0.00% ¹ all samples are equal Co-authored-by: Kovid Goyal Change-Id: Ic7353ce6a0b229cb6aa775bb05044d6bcded7ab2 Reviewed-on: https://go-review.googlesource.com/c/go/+/738280 Auto-Submit: Dmitri Shuralyov Reviewed-by: Dmitri Shuralyov LUCI-TryBot-Result: Go LUCI Reviewed-by: Nigel Tao Reviewed-by: Nigel Tao --- src/image/jpeg/reader.go | 61 ++++++----- src/image/jpeg/reader_test.go | 37 +++++++ src/image/jpeg/scan.go | 106 ++++++++++++------- src/image/testdata/video-001.q50.121121.jpeg | Bin 0 -> 2161 bytes src/image/testdata/video-001.q50.211211.jpeg | Bin 0 -> 2197 bytes src/image/testdata/video-001.q50.221122.jpeg | Bin 0 -> 3454 bytes src/image/testdata/video-001.q50.222112.jpeg | Bin 0 -> 3330 bytes 7 files changed, 138 insertions(+), 66 deletions(-) create mode 100644 src/image/testdata/video-001.q50.121121.jpeg create mode 100644 src/image/testdata/video-001.q50.211211.jpeg create mode 100644 src/image/testdata/video-001.q50.221122.jpeg create mode 100644 src/image/testdata/video-001.q50.222112.jpeg diff --git a/src/image/jpeg/reader.go b/src/image/jpeg/reader.go index 5aa51ad4af..489f950c0c 100644 --- a/src/image/jpeg/reader.go +++ b/src/image/jpeg/reader.go @@ -28,10 +28,12 @@ var errUnsupportedSubsamplingRatio = UnsupportedError("luma/chroma subsampling r // Component specification, specified in section B.2.2. type component struct { - h int // Horizontal sampling factor. - v int // Vertical sampling factor. - c uint8 // Component identifier. - tq uint8 // Quantization table destination selector. + h int // Horizontal sampling factor. + v int // Vertical sampling factor. + c uint8 // Component identifier. + tq uint8 // Quantization table destination selector. + expandH int // Horizontal expansion factor for non-standard subsampling. + expandV int // Vertical expansion factor for non-standard subsampling. } const ( @@ -124,6 +126,10 @@ type decoder struct { blackPix []byte blackStride int + // For non-standard subsampling ratios (flex mode). + flex bool // True if using non-standard subsampling that requires manual pixel expansion. + maxH, maxV int // Maximum horizontal and vertical sampling factors across all components. + ri int // Restart Interval. nComp int @@ -364,30 +370,11 @@ func (d *decoder) processSOF(n int) error { h, v = 1, 1 case 3: - // For YCbCr images, we only support 4:4:4, 4:4:0, 4:2:2, 4:2:0, - // 4:1:1 or 4:1:0 chroma subsampling ratios. This implies that the - // (h, v) values for the Y component are either (1, 1), (1, 2), - // (2, 1), (2, 2), (4, 1) or (4, 2), and the Y component's values - // must be a multiple of the Cb and Cr component's values. We also - // assume that the two chroma components have the same subsampling - // ratio. - switch i { - case 0: // Y. - // We have already verified, above, that h and v are both - // either 1, 2 or 4, so invalid (h, v) combinations are those - // with v == 4. - if v == 4 { - return errUnsupportedSubsamplingRatio - } - case 1: // Cb. - if d.comp[0].h%h != 0 || d.comp[0].v%v != 0 { - return errUnsupportedSubsamplingRatio - } - case 2: // Cr. - if d.comp[1].h != h || d.comp[1].v != v { - return errUnsupportedSubsamplingRatio - } - } + // For YCbCr images, we support both standard subsampling ratios + // (4:4:4, 4:4:0, 4:2:2, 4:2:0, 4:1:1, 4:1:0) and non-standard ratios + // where components may have different sampling factors. The only + // restriction is that each component's sampling factors must evenly + // divide the maximum factors (validated after the loop). case 4: // For 4-component images (either CMYK or YCbCrK), we only support two @@ -415,9 +402,27 @@ func (d *decoder) processSOF(n int) error { } } + d.maxH, d.maxV = max(d.maxH, h), max(d.maxV, v) d.comp[i].h = h d.comp[i].v = v } + + // For 3-component images, validate that maxH and maxV are evenly divisible + // by each component's sampling factors. + if d.nComp == 3 { + for i := 0; i < 3; i++ { + if d.maxH%d.comp[i].h != 0 || d.maxV%d.comp[i].v != 0 { + return errUnsupportedSubsamplingRatio + } + } + } + + // Compute expansion factors for each component. + for i := 0; i < d.nComp; i++ { + d.comp[i].expandH = d.maxH / d.comp[i].h + d.comp[i].expandV = d.maxV / d.comp[i].v + } + return nil } diff --git a/src/image/jpeg/reader_test.go b/src/image/jpeg/reader_test.go index 0872f5e91d..8b6eb76d41 100644 --- a/src/image/jpeg/reader_test.go +++ b/src/image/jpeg/reader_test.go @@ -546,6 +546,43 @@ func TestBadRestartMarker(t *testing.T) { } } +// TestDecodeFlexSubsampling tests that decoding images with non-standard +// (flex) subsampling ratios works correctly. +func TestDecodeFlexSubsampling(t *testing.T) { + // These test cases have non-standard subsampling ratios where either: + // - Cb and Cr have different sampling factors, or + // - Y doesn't have the maximum sampling factors. + testCases := []struct { + name string + filename string + }{ + {"2x2,1x1,2x2", "../testdata/video-001.q50.221122.jpeg"}, // Cb differs from Cr + {"2x1,1x2,1x1", "../testdata/video-001.q50.211211.jpeg"}, // All three differ + {"2x2,2x1,1x2", "../testdata/video-001.q50.222112.jpeg"}, // All three differ + {"1x2,1x1,2x1", "../testdata/video-001.q50.121121.jpeg"}, // Y not max, all differ + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m, err := decodeFile(tc.filename) + if err != nil { + t.Fatalf("decodeFile(%q): %v", tc.filename, err) + } + // All video-001 images are 150x103. + if got, want := m.Bounds(), image.Rect(0, 0, 150, 103); got != want { + t.Errorf("bounds: got %v, want %v", got, want) + } + // Flex subsampling should produce YCbCr images with 4:4:4 ratio. + ycbcr, ok := m.(*image.YCbCr) + if !ok { + t.Fatalf("got %T, want *image.YCbCr", m) + } + if got, want := ycbcr.SubsampleRatio, image.YCbCrSubsampleRatio444; got != want { + t.Errorf("subsample ratio: got %v, want %v", got, want) + } + }) + } +} + func benchmarkDecode(b *testing.B, filename string) { data, err := os.ReadFile(filename) if err != nil { diff --git a/src/image/jpeg/scan.go b/src/image/jpeg/scan.go index de82a29bff..c8a774d97f 100644 --- a/src/image/jpeg/scan.go +++ b/src/image/jpeg/scan.go @@ -16,28 +16,37 @@ func (d *decoder) makeImg(mxx, myy int) { return } - h0 := d.comp[0].h - v0 := d.comp[0].v - hRatio := h0 / d.comp[1].h - vRatio := v0 / d.comp[1].v - var subsampleRatio image.YCbCrSubsampleRatio - switch hRatio<<4 | vRatio { - case 0x11: - subsampleRatio = image.YCbCrSubsampleRatio444 - case 0x12: - subsampleRatio = image.YCbCrSubsampleRatio440 - case 0x21: - subsampleRatio = image.YCbCrSubsampleRatio422 - case 0x22: - subsampleRatio = image.YCbCrSubsampleRatio420 - case 0x41: - subsampleRatio = image.YCbCrSubsampleRatio411 - case 0x42: - subsampleRatio = image.YCbCrSubsampleRatio410 - default: - panic("unreachable") + // Determine if we need flex mode for non-standard subsampling. + // Flex mode is needed when: + // - Cb and Cr have different sampling factors, or + // - The Y component doesn't have the maximum sampling factors, or + // - The ratio doesn't match any standard YCbCrSubsampleRatio. + subsampleRatio := image.YCbCrSubsampleRatio444 + if d.comp[1].h != d.comp[2].h || d.comp[1].v != d.comp[2].v || + d.maxH != d.comp[0].h || d.maxV != d.comp[0].v { + d.flex = true + } else { + hRatio := d.maxH / d.comp[1].h + vRatio := d.maxV / d.comp[1].v + switch hRatio<<4 | vRatio { + case 0x11: + subsampleRatio = image.YCbCrSubsampleRatio444 + case 0x12: + subsampleRatio = image.YCbCrSubsampleRatio440 + case 0x21: + subsampleRatio = image.YCbCrSubsampleRatio422 + case 0x22: + subsampleRatio = image.YCbCrSubsampleRatio420 + case 0x41: + subsampleRatio = image.YCbCrSubsampleRatio411 + case 0x42: + subsampleRatio = image.YCbCrSubsampleRatio410 + default: + d.flex = true + } } - m := image.NewYCbCr(image.Rect(0, 0, 8*h0*mxx, 8*v0*myy), subsampleRatio) + + m := image.NewYCbCr(image.Rect(0, 0, 8*d.maxH*mxx, 8*d.maxV*myy), subsampleRatio) d.img3 = m.SubImage(image.Rect(0, 0, d.width, d.height)).(*image.YCbCr) if d.nComp == 4 { @@ -143,9 +152,11 @@ func (d *decoder) processSOS(n int) error { } // mxx and myy are the number of MCUs (Minimum Coded Units) in the image. - h0, v0 := d.comp[0].h, d.comp[0].v // The h and v values from the Y components. - mxx := (d.width + 8*h0 - 1) / (8 * h0) - myy := (d.height + 8*v0 - 1) / (8 * v0) + // The MCU dimensions are based on the maximum sampling factors. + // For standard subsampling, maxH/maxV equals h0/v0 (Y's factors). + // For flex mode, Y may not have the maximum factors. + mxx := (d.width + 8*d.maxH - 1) / (8 * d.maxH) + myy := (d.height + 8*d.maxV - 1) / (8 * d.maxV) if d.img1 == nil && d.img3 == nil { d.makeImg(mxx, myy) } @@ -439,16 +450,15 @@ func (d *decoder) refineNonZeroes(b *block, zig, zigEnd, nz, delta int32) (int32 } func (d *decoder) reconstructProgressiveImage() error { - // The h0, mxx, by and bx variables have the same meaning as in the + // The mxx, by and bx variables have the same meaning as in the // processSOS method. - h0 := d.comp[0].h - mxx := (d.width + 8*h0 - 1) / (8 * h0) + mxx := (d.width + 8*d.maxH - 1) / (8 * d.maxH) for i := 0; i < d.nComp; i++ { if d.progCoeffs[i] == nil { continue } - v := 8 * d.comp[0].v / d.comp[i].v - h := 8 * d.comp[0].h / d.comp[i].h + v := 8 * d.maxV / d.comp[i].v + h := 8 * d.maxH / d.comp[i].h stride := mxx * d.comp[i].h for by := 0; by*v < d.height; by++ { for bx := 0; bx*h < d.width; bx++ { @@ -469,6 +479,15 @@ func (d *decoder) reconstructBlock(b *block, bx, by, compIndex int) error { b[unzig[zig]] *= qt[zig] } idct(b) + + var h, v int + if d.flex { + // Flex mode: scale bx and by according to the component's sampling factors. + h = d.comp[compIndex].expandH + v = d.comp[compIndex].expandV + bx, by = bx*h, by*v + } + dst, stride := []byte(nil), 0 if d.nComp == 1 { dst, stride = d.img1.Pix[8*(by*d.img1.Stride+bx):], d.img1.Stride @@ -486,20 +505,31 @@ func (d *decoder) reconstructBlock(b *block, bx, by, compIndex int) error { return UnsupportedError("too many components") } } + + if d.flex { + // Flex mode: expand each source pixel to h×v destination pixels. + for y := 0; y < 8; y++ { + y8 := y * 8 + yv := y * v + for x := 0; x < 8; x++ { + val := uint8(max(0, min(255, b[y8+x]+128))) + xh := x * h + for yy := 0; yy < v; yy++ { + for xx := 0; xx < h; xx++ { + dst[(yv+yy)*stride+xh+xx] = val + } + } + } + } + return nil + } + // Level shift by +128, clip to [0, 255], and write to dst. for y := 0; y < 8; y++ { y8 := y * 8 yStride := y * stride for x := 0; x < 8; x++ { - c := b[y8+x] - if c < -128 { - c = 0 - } else if c > 127 { - c = 255 - } else { - c += 128 - } - dst[yStride+x] = uint8(c) + dst[yStride+x] = uint8(max(0, min(255, b[y8+x]+128))) } } return nil diff --git a/src/image/testdata/video-001.q50.121121.jpeg b/src/image/testdata/video-001.q50.121121.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7b7964ef92a122da1d577d29c77a5c7ab460be03 GIT binary patch literal 2161 zcmb7;c|6mPAICqlv2skC(cG~Sk(-F-%5oH~ug#Tml~|4?_mPzQzK?R>vXJpDQ{?N+ z_vct5R*p6yN7Nb}`0Dp~{C5JjDen}?kOYl%hpC`?aWT;Ui87+U3K%k z;fcLK4)ym4xqQvj{dWimjzFMqfk6m>3l0LyfcAR< z@q?b+zuW#79)JrB;RZnu-7YGanaUXE_-wPlvFaUvbBV^Qhgz&Cpq^$AXa&%_P z%W+MUTLGI;VG)d+hK^g-!L29=0E7M~7yyB}AlwH^77lRz=D`2wD~JmYmO-cs8ACMi zNZI`vfFBGxC<%rGhQLv9nio^M&y%W;I;!l;qE@=!X*cBtSzem&#|lnYwz7(^>+{Y(6U`E1#1-c@Xs{ z3p=~DiCQCHaP1876UgmQQFL@fL?3}o)>iak`>K_Rai*sIYVJKmXJ}5O$I~gR@F#OG zR`{NQ$>{Qx9nht`!f~D-*Z9{vZ6AQ=S%Sn)8Z2Qn%wE^JJT| zL#ub0_D;*Wg#lLWqBBE#g^+zfBsQ2lG0%G~wXd^7OHTYS?^=P-t96N`siu(*#h-d5 zX2WKFM1fVrIqk1n9+}awC}>Ze@rl1{!6d==+7d|?4DP(c9D)V5VC@giL}QS?Xi|uy zGEw7j{9B7(@}>S=i>y7N9pd~xK)0504iKNYCW=@t8G%LE(NY~Apvm>#9q z?achNG-WT)#DbL1NZ?KFT#>39JQMiTHEe$V1 zuT`h!Rj%VU-r8WCaHvK9*((NOPfS~!xF_z*#9t|Od^Te*+cK$gEKV*-m$PoRtWmbL zU34j}T|`^HL++xeX-EpaZJWZx5*AsPJ;vvfk7I}<>-FmP&7zJA7VY^!sXv=daS||O zP&US|y5}5{jPEEbv7u99FE?!}r&LwdgJ0|TRWZhS7d>C-6% zurgkSZfP^Pd$dfI7R~dMH>Fy~wDo@moO#~7F(6aS*s5{hEtF`4t}Z#5>WY@Z@9o59j*Q(_^tV<6Yt$6?r?K+wo7YJ*Nt4(OofSq9afUtP7084kNQL|DxSu zZzq5kO;}@W8qbGO13INTOXOVd`6}`;m(VW*xnYo*Yt_Ba*IM!-d`7-*pw#q+z1RFL%8%NsxIZf@8P&C+5IH|PH4 z!NjB^6e@oiR=Z2}$7h6IH%Kq`)d=X`MI(t!4 z*cu@XarGY4$&Hw-FMMMpGx+jj6A6(RXcdQY5%-PcF+7peN?j)RR6(|P8`_+7(W%3` z1p>{1=mmSn+(wbzDKc-R&YhVv&?Va*rgoFj{7)I>C)P=R;kSPjd1GQTB^}_;09Z_A zPV}tP)5k^ftm)j!H*7uDv+>4{Irq@LeD3|_YfPbOnmj)HT?{wUo3SR~SmQe=eVxA$ zVC`g<4olS(tMRE2Z+`8}v!kW`eRc`G(67s;C@`$p#Ot@1Qn)+?5#?a@8vS{PG+5;4 z5S6G6Y~a}5$#0PQH>r;dmMb*5jD{L&?4bH-B#G3Wx#g!+hqlj+pR-6_IuAF)ekJVC-;F$&;1B%R zW>Mf3lg+;3bA`Y7)8al5vKY4eEePVu8=OFE83CGfZViZPpNlfY$5H6sE`iQKyU2&yHp#XJpADFn*gg*YhBu%wUdVl&K DkMP#% literal 0 HcmV?d00001 diff --git a/src/image/testdata/video-001.q50.211211.jpeg b/src/image/testdata/video-001.q50.211211.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..bae5cecc544a64ed8a54ee0c11bc16dc312e33de GIT binary patch literal 2197 zcmb79c{tRI8vczLMs_pyt;5hTjcg}|h{TMsFIh@qY-7z49rZ84KPL)HkTbOtURraFwT)Kp1%IX z5aL;f7;<#rjmtsAfFC9xNf=B{T24nnK_|cxV;S&&gU13S1py*J2ZK}rJ{Slr3F37F zibp;9f3*ECKwt>J03Y;-G?4)KKwvOP01Sa1`}YBe4-7yg`C+Ph0`PM#gcvo-h?IV5 zJyZr^8Jqn=-7w(Vt(@HX?IW!`2mpir$M{GK0r-z74M~9S2S5<;e^Nnwl3-Ps9zUe&7n%C7ss2u5RLTAd1eelP>MowWLRoi(yhnu~Pngi?+? z{n$^jIpqkl?h`$Gp!WA?E?5@4gEplm4=1Y{iT3sp^XEMSx3IfT>6|ebW^)lQJ_{09 z+%*e5{L|xHlZ{pNjTi2Sozuh$nZ3XaZ_+?kBS9%PetbvHd3%H@CSLNfy5oTv%E_WQ zxVb}M$ks5yxjuqmnBdZo&t}#bx*6G}C6Ah$R3yrr?Zf|7|FJ!PB{CX{r80Gw=g9Y4 zXsjtj^6dW3`ZpBoVwd)4tVWF@4@i34`t}eoa9<=~$)s0v+{qV$b4cee{HkjfW-;$S z)_f2}m|V$YCy`DFmQlvNl5{Fb2&B^W3S%VF=5EfFe9n-ARBM9wU<}+b!?H92<13y_ zvun|iPKi-qpto4kzYKp3%h<_@$6|Lc%C4#rU@4Q(dw6+e2khkHIMZPxqIrho0l&*7 zFu9q3&`$E_)stKr(wZbD!pzndCU^ks`*p8GRln~xM<0E1Zc(QU>IdEJLf;e!uX$ve zG@(J)_10Kz&67#U$H&a!Kpvh!^6RgusFBWk#(AxPs>etO%Bq_On7UcNR~W}I(e1xZ zH&8E*YVRwu>2tCc@K7Rw-plyS*Q6HbndyxY^qCV47!6gQ~d;|ej1xT?=xQ++bdju?(?88B+SrL;Q+zu&&{hMXT&z+{MxegXQ7>_ zm3;%38}2I0fI+h=nw7M}HVRO8&0lX{oQVFQZAD9L%;cm{WcDee*sPT(>yr zXgemA^EK6E3cXqAAc5Qx`^TktVkKGJ@ya{BNCb1SG-~pTiQXAZIU`7a7ak4o7S5gl*sJLL@{E z4V|uoRaNxV%B`rlM$|WKB-vL!`7oW^8cVoS%pSFiokK;Z_yBc?!Wd^maRGu`WdmN_pBxzQJ2V@k<--?t&*9rR3G*~0a5p5g zbQSp)X*f0l201#wf$+~Y%gZSqobCHYMR4KhcO9D^s;Q zS0CB)qw(M@LALrOEvq2)SD?BuKZz)H<1?LTy`h%nN1;X4?abt9mA~}K#$DpY;|$I9 z^Lz#&!|a;XscIe&nl*-$B6e-L4Fj*_vmA9XwZevD=(rRER(}Z%Vie#Tde^^&(0B)DTr$%wEYjpz zHI=;c#aS^8{ccOm1H525-)tJ~wD&ODLqiL5TB7PJ^sey&zDX6$nq?X)FR^v3RkB?Jm3N7jHF-3MxugZ zKDF5}rkOW5Gb_i<70Na}IvCTTpGha8KRc#ELi=5DR2o^en0cy+iZLi%t2pGMNBClk zQ-7lOM2EOZ>1R}Zzp($TVG>552fFpXWGAEIB^dtR_D_Gmx=bZMxcx_bFa2!GpmgfN zVAIABJ=w^9PptvN2zsoX%@iw9X)HDeVmmBYd(w|zzAwb}y%+s9d~;0|*%1{s#W6sP znB$UXznBl0FP(I^c$*5Xgl8FFxNuyELZMJ~Q1}3RK*&4mYo^kKd*QjEQy3A#&+D2q zrlfl(`2^K;1()w{$V9dm*ob|!`(+@>XP>wfcy;=FmNfSCi0_p5{nv?SOzDaKs8Y|6 zp5df=ZhIM)nEh~q3Bv~)tFQc7g>R}YbVdA27ft%aKe_#>YEGHot~?#$s^5e~ jMWETYiB+qpY)oF;sfdXJE96xAM}3XK!UVm29B=Y(g=*re literal 0 HcmV?d00001 diff --git a/src/image/testdata/video-001.q50.221122.jpeg b/src/image/testdata/video-001.q50.221122.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..463063f9d6279db507f9e396ed1883b97cad34cf GIT binary patch literal 3454 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%0D15vGyY}sEYf8gH$k%EiU zM!2CdOY@(yyy1d?&=6^{%Z*zK*CxmUT`&yvQvDizd{G{Zz-YbiR1m~_7h$HT*;u?=l z&?rmiJMF#XAJ#%8L&}Xa1M>OL0Kx7bZ*!Ze8$-kk6Buy<;_~n2hRTu5$(01N$pJ^7k(sM0 z#WUr7d>RjS>OvBt#_3&?n*B3@MPu45)80=?ujsDjdI(xn!^ij~Z?6^*Cu9(D=Jq$} zSW5HU`9h9^-#>CP)8w{U$Z@$mnBEvmrB00t3C$HDSjxAodc||B&XA8U>O(R$e~VqT z21=$>gs|6Okq@Vld{D`3Z)tC!4m|K8pT4-g-mJ7(~b-`nH}d{|U8uNh#cnZ?@Urnz9-M{Ak*>f53JW`dLq1gP=h;8UrpaX47M zJoC{Few0FFi89;@At8mA@N4`aA?7Zn@iQPZ-_odIxJPWHvOeB-va%td?L-*WjT`fq z**ENI>Lqoo;)iR4W zR;si#EmCdAv;*Sr))eXpm&R{@tp{3sb|8ieC~?oDvLdue>c~FN(*F3CXfyNiwvh;r z8btjhIP4nT0})2#4`@J=(Jn&Mcjm};{fB8b6jdgkovv5>r|!|1yZ2v`?S?Au2FFHK z6ZPtJoesJQ&sytRCm2Wa>iYEWEDL=zrQK044ip_wAzl8WK(N+|W* zA47%IQlY+GP_2mmc<_-Pvtjakyu$tkw=2Ljv#9$WkgqbOq7UMIe2evHmt0qD-={{`_JLQ((# literal 0 HcmV?d00001 diff --git a/src/image/testdata/video-001.q50.222112.jpeg b/src/image/testdata/video-001.q50.222112.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f0f0955bd7500ab045f116ec234842de09fb9744 GIT binary patch literal 3330 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