Skip to content

Commit 6219234

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

File tree

4 files changed

+47
-70
lines changed

4 files changed

+47
-70
lines changed

Tests/test_file_jxl_animated.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ def test_n_frames() -> None:
2323

2424
def test_float_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:

Tests/test_file_jxl_metadata.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 27 additions & 47 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,13 +37,15 @@ 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
@@ -57,6 +57,7 @@ def _open(self) -> None:
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,51 @@ 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+
while self.__frame < frame:
93+
self.__frame += 1
94+
self._get_next()
95+
if self._n_frames is not None and self._n_frames < frame:
96+
msg = "no more images in JPEG XL file"
97+
raise EOFError(msg)
11798

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

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

129109
return super().load()
130110

131111
def load_seek(self, pos: int) -> None:
132112
pass
133113

134114
def tell(self) -> int:
135-
return self.__logical_frame
115+
return self.__frame
136116

137117

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

src/_jpegxl.c

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ typedef struct {
8585
Py_ssize_t jxl_data_len; // length of input jxl bitstream
8686

8787
uint8_t *outbuf;
88-
Py_ssize_t outbuf_len;
88+
size_t outbuf_len;
8989

9090
uint8_t *jxl_icc;
91-
Py_ssize_t jxl_icc_len;
91+
size_t jxl_icc_len;
9292
uint8_t *jxl_exif;
9393
Py_ssize_t jxl_exif_len;
9494
uint8_t *jxl_xmp;
@@ -317,7 +317,7 @@ _jxl_decoder_new(PyObject *self, PyObject *args) {
317317
continue;
318318
}
319319

320-
size_t cur_compr_box_size;
320+
uint64_t cur_compr_box_size;
321321
decp->status = JxlDecoderGetBoxSizeRaw(decp->decoder, &cur_compr_box_size);
322322
_JXL_CHECK("JxlDecoderGetBoxSizeRaw");
323323

@@ -396,16 +396,21 @@ _jxl_decoder_new(PyObject *self, PyObject *args) {
396396
PyObject *
397397
_jxl_decoder_get_info(PyObject *self) {
398398
JpegXlDecoderObject *decp = (JpegXlDecoderObject *)self;
399-
399+
JxlFrameHeader fhdr = {};
400+
if (JxlDecoderGetFrameHeader(decp->decoder, &fhdr) != JXL_DEC_SUCCESS) {
401+
PyErr_SetString(PyExc_OSError, "Error determining duration");
402+
return NULL;
403+
}
400404
return Py_BuildValue(
401-
"(II)sOIII",
405+
"(II)sOIIII",
402406
decp->basic_info.xsize,
403407
decp->basic_info.ysize,
404408
decp->mode,
405409
decp->basic_info.have_animation ? Py_True : Py_False,
406410
decp->basic_info.animation.tps_numerator,
407411
decp->basic_info.animation.tps_denominator,
408-
decp->basic_info.animation.num_loops
412+
decp->basic_info.animation.num_loops,
413+
fhdr.duration
409414
);
410415
}
411416

@@ -426,11 +431,6 @@ _jxl_decoder_get_next(PyObject *self) {
426431
while (decp->status != JXL_DEC_NEED_IMAGE_OUT_BUFFER) {
427432
decp->status = JxlDecoderProcessInput(decp->decoder);
428433

429-
// every frame was decoded successfully
430-
if (decp->status == JXL_DEC_SUCCESS) {
431-
Py_RETURN_NONE;
432-
}
433-
434434
// this should only occur after rewind
435435
if (decp->status == JXL_DEC_NEED_MORE_INPUT) {
436436
_jxl_decoder_set_input((PyObject *)decp);
@@ -454,7 +454,7 @@ _jxl_decoder_get_next(PyObject *self) {
454454
uint8_t *_new_outbuf = realloc(decp->outbuf, decp->outbuf_len);
455455
if (!_new_outbuf) {
456456
PyErr_SetString(PyExc_OSError, "failed to allocate outbuf");
457-
goto end_with_custom_error;
457+
return NULL;
458458
}
459459
decp->outbuf = _new_outbuf;
460460
}
@@ -469,7 +469,7 @@ _jxl_decoder_get_next(PyObject *self) {
469469

470470
if (decp->status != JXL_DEC_FULL_IMAGE) {
471471
PyErr_SetString(PyExc_OSError, "failed to read next frame");
472-
goto end_with_custom_error;
472+
return NULL;
473473
}
474474

475475
bytes = PyBytes_FromStringAndSize((char *)(decp->outbuf), decp->outbuf_len);
@@ -493,13 +493,6 @@ _jxl_decoder_get_next(PyObject *self) {
493493
decp->status
494494
);
495495
PyErr_SetString(PyExc_OSError, err_msg);
496-
497-
end_with_custom_error:
498-
499-
// no need to deallocate anything here
500-
// user can just ignore error
501-
502-
return NULL;
503496
}
504497

505498
PyObject *

0 commit comments

Comments
 (0)