Skip to content

Commit e7841b3

Browse files
committed
Fix _get_pixels infinite recursion on Pillow <12
Production traceback from a macOS user: File ".../frame_extractor.py", line 238, in classify_video pixels = _get_pixels(img) File ".../frame_extractor.py", line 179, in _get_pixels return _get_pixels(img) # ← self-recursion on same arg [Previous line repeated 992 more times] RecursionError: maximum recursion depth exceeded The old fallback branch was a stub — it called itself instead of hitting a real API. Local test environments have Pillow 12+ where get_flattened_data() exists, so the branch was never exercised in CI; it only fired on users with Pillow <12. Fix: use list(img.getdata()) on the pre-Pillow-12 path. Both APIs return the same shape (tuples for RGB, ints for L mode). test/test_v0_12_fixes.py: +5 tests covering both Pillow branches, an old-Pillow simulation via a fake image without get_flattened_data, a stack-depth guard via sys.settrace to prove the function is O(1), and an end-to-end classify_video() smoke test. Release: v0.12.11.
1 parent d6d99c8 commit e7841b3

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "auto-note",
3-
"version": "0.12.10",
3+
"version": "0.12.11",
44
"description": "AutoNote — lecture notes generator from Canvas recordings",
55
"homepage": "https://github.com/nodeeeeee/Auto-Note",
66
"author": {

frame_extractor.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,19 @@ def _parse_ffmpeg_duration(stderr: str | None) -> float | None:
173173

174174

175175
def _get_pixels(img):
176-
"""Get pixel data from a PIL Image, compatible with Pillow 14+."""
176+
"""Get pixel data from a PIL Image, compatible with old and new Pillow.
177+
178+
Pillow 12+ introduces `get_flattened_data()` and deprecates `getdata()`
179+
(removed in Pillow 14). Both return the same shape — a 1-D sequence of
180+
per-pixel values (tuples for RGB, ints for L).
181+
182+
The old fallback was `return _get_pixels(img)`, which recurses infinitely
183+
on Pillow < 12 (no `get_flattened_data`) and crashed the frame extractor
184+
on one user's install with a RecursionError at classify_video time.
185+
"""
177186
if hasattr(img, 'get_flattened_data'):
178187
return list(img.get_flattened_data())
179-
return _get_pixels(img)
188+
return list(img.getdata())
180189

181190

182191
# ── Screen vs Camera auto-detection ──────────────────────────────────────────

test/test_v0_12_fixes.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,101 @@ def test_downloader_has_resolvers(self):
754754
assert callable(getattr(downloader, "_resolve_ffmpeg"))
755755
assert callable(getattr(downloader, "_resolve_panopto_tool_id"))
756756
assert callable(getattr(downloader, "_run_ffmpeg_hls"))
757+
758+
759+
# ══════════════════════════════════════════════════════════════════════════════
760+
# v0.12.11 — frame_extractor._get_pixels infinite-recursion regression
761+
# ══════════════════════════════════════════════════════════════════════════════
762+
763+
class TestGetPixelsCompat:
764+
"""Regression for v0.12.11.
765+
766+
The old _get_pixels() was:
767+
def _get_pixels(img):
768+
if hasattr(img, 'get_flattened_data'):
769+
return list(img.get_flattened_data())
770+
return _get_pixels(img) # ← infinite self-recursion on Pillow <12
771+
772+
This crashed with RecursionError at classify_video time on every user
773+
whose Pillow version pre-dates get_flattened_data (12.0, Oct 2024). The
774+
fix is to call list(img.getdata()) on the fallback branch.
775+
"""
776+
777+
def test_rgb_returns_tuples(self):
778+
from PIL import Image
779+
import frame_extractor as fe
780+
img = Image.new("RGB", (3, 2), "red")
781+
data = fe._get_pixels(img)
782+
assert len(data) == 6
783+
assert data[0] == (255, 0, 0)
784+
785+
def test_grayscale_returns_ints(self):
786+
from PIL import Image
787+
import frame_extractor as fe
788+
img = Image.new("L", (2, 2), 128)
789+
assert fe._get_pixels(img) == [128, 128, 128, 128]
790+
791+
def test_old_pillow_path_no_recursion(self):
792+
"""Simulate Pillow <12: object has getdata() but NOT get_flattened_data.
793+
794+
Before the fix, this raised RecursionError immediately.
795+
"""
796+
import frame_extractor as fe
797+
798+
class FakeOldPillowImg:
799+
def getdata(self):
800+
return [(1, 2, 3), (4, 5, 6)]
801+
# Crucially — no get_flattened_data
802+
803+
assert not hasattr(FakeOldPillowImg(), "get_flattened_data")
804+
# This call used to recurse until Python raised RecursionError.
805+
result = fe._get_pixels(FakeOldPillowImg())
806+
assert result == [(1, 2, 3), (4, 5, 6)]
807+
808+
def test_depth_is_constant_not_recursive(self):
809+
"""Guard: _get_pixels must be O(1) stack depth, not recursive.
810+
811+
If the bug regressed, calling with a trivial object would add thousands
812+
of frames before RecursionError. Here we just count that at most one
813+
extra frame is added while inside _get_pixels.
814+
"""
815+
import sys as _sys
816+
import frame_extractor as fe
817+
818+
class FakeOldPillowImg:
819+
def getdata(self):
820+
return [0] * 10
821+
822+
baseline_depth = {"d": None}
823+
824+
def tracer(frame, event, arg):
825+
if event == "call" and frame.f_code.co_name == "_get_pixels":
826+
# Measure stack depth at entry
827+
depth = 0
828+
f = frame
829+
while f:
830+
depth += 1
831+
f = f.f_back
832+
if baseline_depth["d"] is None:
833+
baseline_depth["d"] = depth
834+
else:
835+
# If recursive, this nested call would be baseline+1
836+
assert depth <= baseline_depth["d"], \
837+
"_get_pixels must not recurse"
838+
return tracer
839+
840+
old_tracer = _sys.gettrace()
841+
_sys.settrace(tracer)
842+
try:
843+
fe._get_pixels(FakeOldPillowImg())
844+
finally:
845+
_sys.settrace(old_tracer)
846+
847+
def test_classify_video_does_not_recurse(self, tmp_path):
848+
"""End-to-end: classify_video on a real sample must not hit recursion."""
849+
import frame_extractor as fe
850+
vp = PROJECT_DIR / "sample" / "85397" / "videos" / "Lecture 9 Link Layer ARP.mp4"
851+
if not vp.exists():
852+
pytest.skip("Sample video unavailable")
853+
result = fe.classify_video(vp)
854+
assert result in ("screen", "camera")

0 commit comments

Comments
 (0)