Skip to content

Commit 612de5a

Browse files
committed
Populate duration before load
1 parent 5e3dc40 commit 612de5a

File tree

6 files changed

+100
-152
lines changed

6 files changed

+100
-152
lines changed

Tests/test_file_jxl.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_version(self) -> None:
3737

3838
def test_read_rgb(self) -> None:
3939
"""
40-
Can we read a RGB mode Jpeg XL file without error?
40+
Can we read an RGB mode JPEG XL file without error?
4141
Does it have the bits we expect?
4242
"""
4343

@@ -52,9 +52,22 @@ def test_read_rgb(self) -> None:
5252
# djxl hopper.jxl hopper_jxl_bits.ppm
5353
assert_image_similar_tofile(im, "Tests/images/hopper_jxl_bits.ppm", 1)
5454

55+
def test_read_rgba(self) -> None:
56+
# Generated with `cjxl transparent.png transparent.jxl -q 100 -e 8`
57+
with Image.open("Tests/images/transparent.jxl") as im:
58+
assert im.mode == "RGBA"
59+
assert im.size == (200, 150)
60+
assert im.format == "JPEG XL"
61+
im.load()
62+
im.getdata()
63+
64+
im.tobytes()
65+
66+
assert_image_similar_tofile(im, "Tests/images/transparent.png", 1)
67+
5568
def test_read_i16(self) -> None:
5669
"""
57-
Can we read 16-bit Grayscale Jpeg XL image?
70+
Can we read 16-bit Grayscale JPEG XL image?
5871
"""
5972

6073
with Image.open("Tests/images/jxl/16bit_subcutaneous.cropped.jxl") as im:

Tests/test_file_jxl_alpha.py

Lines changed: 0 additions & 26 deletions
This file was deleted.

Tests/test_file_jxl_animated.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ def test_n_frames() -> None:
2121
assert im.is_animated
2222

2323

24-
def test_float_duration() -> None:
24+
def test_duration() -> None:
2525
with Image.open("Tests/images/iss634.jxl") as im:
26-
im.load()
2726
assert im.info["duration"] == 70
27+
assert im.info["timestamp"] == 0
28+
29+
im.seek(2)
30+
assert im.info["duration"] == 60
31+
assert im.info["timestamp"] == 140
2832

2933

3034
def test_seek() -> None:
@@ -62,8 +66,11 @@ def test_seek() -> None:
6266

6367
def test_seek_errors() -> None:
6468
with Image.open("Tests/images/iss634.jxl") as im:
65-
with pytest.raises(EOFError):
69+
with pytest.raises(EOFError, match="attempt to seek outside sequence"):
6670
im.seek(-1)
6771

68-
with pytest.raises(EOFError):
72+
im.seek(1)
73+
with pytest.raises(EOFError, match="no more images in JPEG XL file"):
6974
im.seek(47)
75+
76+
assert im.tell() == 1

Tests/test_file_jxl_metadata.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_read_exif_metadata() -> None:
3939
with Image.open("Tests/images/flower.jpg") as im_jpeg:
4040
expected_exif = im_jpeg.info["exif"]
4141

42-
# jpeg xl always returns exif without 'Exif\0\0' prefix
42+
# JPEG XL always returns exif without 'Exif\0\0' prefix
4343
assert exif_data == expected_exif[6:]
4444

4545

@@ -97,8 +97,8 @@ class JpegXlDecoder:
9797
def __init__(self, b: bytes) -> None:
9898
pass
9999

100-
def get_info(self) -> tuple[tuple[int, int], str, int, int, int, int]:
101-
return ((1, 1), "L", 0, 0, 0, 0)
100+
def get_info(self) -> tuple[tuple[int, int], str, int, int, int, int, int]:
101+
return ((1, 1), "L", 0, 0, 0, 0, 0)
102102

103103
def get_icc(self) -> None:
104104
pass

src/PIL/JpegXlImagePlugin.py

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ def _accept(prefix: bytes) -> bool | str:
2525
class JpegXlImageFile(ImageFile.ImageFile):
2626
format = "JPEG XL"
2727
format_description = "JPEG XL image"
28-
__loaded = -1
29-
__logical_frame = 0
30-
__physical_frame = 0
28+
__frame = 0
3129

3230
def _open(self) -> None:
3331
self._decoder = _jpegxl.JpegXlDecoder(self.fp.read())
@@ -39,24 +37,27 @@ def _open(self) -> None:
3937
tps_num,
4038
tps_denom,
4139
self.info["loop"],
40+
tps_duration,
4241
) = self._decoder.get_info()
4342

4443
self._n_frames = None if self.is_animated else 1
4544
self._tps_dur_secs = tps_num / tps_denom if tps_denom != 0 else 1
45+
self.info["duration"] = 1000 * tps_duration * (1 / self._tps_dur_secs)
4646

4747
# TODO: handle libjxl time codes
48-
self.__timestamp = 0
48+
self.info["timestamp"] = 0
4949

5050
if icc := self._decoder.get_icc():
5151
self.info["icc_profile"] = icc
5252
if exif := self._decoder.get_exif():
53-
# jpeg xl does some weird shenanigans when storing exif
53+
# JPEG XL does some weird shenanigans when storing exif
5454
# it omits first 6 bytes of tiff header but adds 4 byte offset instead
5555
if len(exif) > 4:
5656
exif_start_offset = struct.unpack(">I", exif[:4])[0]
5757
self.info["exif"] = exif[exif_start_offset + 4 :]
5858
if xmp := self._decoder.get_xmp():
5959
self.info["xmp"] = xmp
60+
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
6061

6162
@property
6263
def n_frames(self) -> int:
@@ -67,72 +68,53 @@ def n_frames(self) -> int:
6768

6869
return self._n_frames
6970

70-
def _get_next(self) -> tuple[bytes, float, float]:
71-
# Get next frame
72-
next_frame = self._decoder.get_next()
73-
self.__physical_frame += 1
71+
def _get_next(self) -> bytes:
72+
data, tps_duration, is_last = self._decoder.get_next()
7473

75-
# this actually means EOF, errors are raised in _jxl
76-
if next_frame is None:
77-
msg = "failed to decode next frame in JXL file"
78-
raise EOFError(msg)
79-
80-
data, tps_duration, is_last = next_frame
8174
if is_last and self._n_frames is None:
82-
# libjxl said this frame is the last one
83-
self._n_frames = self.__physical_frame
75+
self._n_frames = self.__frame
8476

8577
# duration in milliseconds
86-
duration = 1000 * tps_duration * (1 / self._tps_dur_secs)
87-
timestamp = self.__timestamp
88-
self.__timestamp += duration
89-
90-
return data, timestamp, duration
78+
self.info["timestamp"] += self.info["duration"]
79+
self.info["duration"] = 1000 * tps_duration * (1 / self._tps_dur_secs)
9180

92-
def _seek(self, frame: int) -> None:
93-
if frame == self.__physical_frame:
94-
return # Nothing to do
95-
if frame < self.__physical_frame:
96-
# also rewind libjxl decoder instance
97-
self._decoder.rewind()
98-
self.__physical_frame = 0
99-
self.__loaded = -1
100-
self.__timestamp = 0
101-
102-
while self.__physical_frame < frame:
103-
self._get_next() # Advance to the requested frame
81+
return data
10482

10583
def seek(self, frame: int) -> None:
106-
if self._n_frames is None:
107-
self.n_frames
10884
if not self._seek_check(frame):
10985
return
11086

111-
# Set logical frame to requested position
112-
self.__logical_frame = frame
87+
if frame < self.__frame:
88+
self.__frame = 0
89+
self._decoder.rewind()
90+
self.info["timestamp"] = 0
11391

114-
def load(self) -> Image.core.PixelAccess | None:
115-
if self.__loaded != self.__logical_frame:
116-
self._seek(self.__logical_frame)
92+
last_frame = self.__frame
93+
while self.__frame < frame:
94+
self._get_next()
95+
self.__frame += 1
96+
if self._n_frames is not None and self._n_frames < frame:
97+
self.seek(last_frame)
98+
msg = "no more images in JPEG XL file"
99+
raise EOFError(msg)
117100

118-
data, self.info["timestamp"], self.info["duration"] = self._get_next()
119-
self.__loaded = self.__logical_frame
101+
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
102+
103+
def load(self) -> Image.core.PixelAccess | None:
104+
if self.tile:
105+
data = self._get_next()
120106

121-
# Set tile
122107
if self.fp and self._exclusive_fp:
123108
self.fp.close()
124-
# this is horribly memory inefficient
125-
# you need probably 2*(raw image plane) bytes of memory
126109
self.fp = BytesIO(data)
127-
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
128110

129111
return super().load()
130112

131113
def load_seek(self, pos: int) -> None:
132114
pass
133115

134116
def tell(self) -> int:
135-
return self.__logical_frame
117+
return self.__frame
136118

137119

138120
Image.register_open(JpegXlImageFile.format, JpegXlImageFile, _accept)

0 commit comments

Comments
 (0)