From af0482a12dbede8605ddbf8edcb9b328a3c77119 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 24 Oct 2026 16:02:43 -0700 Subject: [PATCH] fix(history): let the cover ask for JPEG XL when this browser decodes it Firefox can decode JPEG XL without listing image/jxl in Accept. When the workspace's probe decodes JPEG XL, it adds jxl=1 to the History cover URL. The server serves a .jxl preview when that flag is set or when Accept lists image/jxl. In every other case it falls back as before. The flag accepts only 0 or 1, and the response stays no-store. --- TESTING.md | 6 +++-- py/api/history.py | 14 ++++++++++- tests/js/workspace-history-media.test.js | 19 +++++++++++++++ tests/test_api_history_outputs.py | 30 ++++++++++++++++++++++++ web/js/workspace/history-media.mjs | 7 ++++-- 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/TESTING.md b/TESTING.md index 2caf2bd7..35a4a746 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2360,8 +2360,10 @@ Thumbnail format and settings (#317, `py/api/thumbnail_settings.py`, preview `` that fails to load falls back to the original (the legacy gallery already does). A video whose preview it cannot show keeps its own element (Images) or its "Video preview" label (Library), never a broken image. History's cover URL names - no format, so the server serves a `.jxl` preview only when the request's `Accept` - lists `image/jxl`; otherwise the file's preview in another format, else the original + no format, so the server serves a `.jxl` preview only when the URL carries `jxl=1` + (added by the workspace where its probe decoded JPEG XL: Firefox may not list it in + Accept; any value but `0` or `1` is a 400) or the request's `Accept` lists + `image/jxl`; otherwise the file's preview in another format, else the original image or a video's placeholder. Appearance says when this browser cannot show JPEG XL thumbnails. Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per diff --git a/py/api/history.py b/py/api/history.py index df258153..f50960fb 100644 --- a/py/api/history.py +++ b/py/api/history.py @@ -143,6 +143,18 @@ def _query(request: web.Request) -> None: raise ValueError("Duplicate history query parameter") +def _jxl_flag(request: web.Request) -> bool: + """The cover's ``jxl`` flag (#317): ``1`` when the workspace decoded a + JPEG XL image (a browser may not list ``image/jxl`` in Accept), ``0`` or + absent otherwise; anything else is a bad request.""" + value = request.query.get("jxl") + if value is None: + return False + if value not in ("0", "1"): + raise ValueError("Invalid jxl flag") + return value == "1" + + def _page_limit(request: web.Request) -> int: limit = _decimal(request.query.get("limit", "40"), minimum=1) if limit > 100: @@ -795,7 +807,7 @@ class HistoryRoutesMixin: media_options = { "ensure_preview": lambda *preview: needed.append(preview), "placeholder": self._video_placeholder_bytes(), - "accepts_jxl": accepts_jxl(request), + "accepts_jxl": _jxl_flag(request) or accepts_jxl(request), } prepared = await execute() if needed: diff --git a/tests/js/workspace-history-media.test.js b/tests/js/workspace-history-media.test.js index 2b69f320..05885ff3 100644 --- a/tests/js/workspace-history-media.test.js +++ b/tests/js/workspace-history-media.test.js @@ -40,3 +40,22 @@ test('stale inspection echoes use the canonical recovery vocabulary and identity } }); test('the recorded output inspector cover is its preview, not the original (#249)',()=>{const a=adapter(),target=media('2',1);const record=a.toRecord(envelope(target),target,extractGenerationFacts);assert.deepEqual(record.preview,{url:target.thumbnail_url});assert.notEqual(record.preview.url,record.download.url);}); + +// #317 M3 follow-up: Firefox may decode JPEG XL without listing image/jxl in Accept, so the cover URL says so itself. +test('the recorded output cover asks for JPEG XL only where this browser decodes it',async()=>{ + const support=await import('../../web/js/workspace/thumbnail-support.mjs'); + const a=adapter(),target=media('2',1); + const probe=ok=>({createImage:()=>({set src(_){queueMicrotask(()=>ok?this.onload():this.onerror());}})}); + try { + support.resetJxlSupport(); + assert.equal(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,target.thumbnail_url,'unknown: no flag'); + await support.detectJxlSupport(probe(true)); + const flagged=new URL(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,'http://localhost/'); + assert.equal(flagged.searchParams.get('jxl'),'1'); + assert.equal(flagged.searchParams.getAll('jxl').length,1); + assert.equal(`${flagged.pathname}?${new URLSearchParams([...flagged.searchParams].filter(([k])=>k!=='jxl'))}`,target.thumbnail_url); + support.resetJxlSupport(); + await support.detectJxlSupport(probe(false)); + assert.equal(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,target.thumbnail_url,'no support: no flag'); + } finally {support.resetJxlSupport();} +}); diff --git a/tests/test_api_history_outputs.py b/tests/test_api_history_outputs.py index 6392b02c..c250cc27 100644 --- a/tests/test_api_history_outputs.py +++ b/tests/test_api_history_outputs.py @@ -1238,6 +1238,36 @@ class HistoryOutputAPITests(ImageAPITestCase): self.assertEqual(response.headers["Content-Type"], "image/jxl") self.assertEqual(body, jxl.read_bytes()) + async def test_the_jxl_flag_selects_the_jxl_cover_without_accept(self): + # M3 follow-up: a browser that decodes JPEG XL but does not list it in + # Accept (Firefox) says so with jxl=1 on the cover URL. + make_png(self.output_dir / "image.png") + folder = self.output_dir / "thumbnails" + folder.mkdir(exist_ok=True) + jxl = folder / "image_thumb.jxl" + jxl.write_bytes(b"\xff\x0a jxl preview") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + from py.api import thumbnail_encode + from py.config import PromptManagerConfig + + with ( + patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"), + patch.object(thumbnail_encode, "detect_jxl_encoder", return_value="cjxl"), + ): + url = descriptor["thumbnail_url"] + response = await self.current(url + "&jxl=1", headers={"Accept": "*/*"}) + self.assertEqual(response.headers["Content-Type"], "image/jxl") + self.assertEqual(response.headers["Cache-Control"], "no-store") + self.assertEqual(await response.read(), jxl.read_bytes()) + response = await self.current(url + "&jxl=0", headers={"Accept": "*/*"}) + self.assertEqual(response.headers["Content-Type"], "image/png") + await response.read() + for bad in ("&jxl=true", "&jxl=", "&jxl=1&jxl=1", "&jxl=2"): + response = await self.current(url + bad) + self.assertEqual(response.status, 400, bad) + await response.read() + async def test_a_video_jxl_cover_is_the_placeholder_without_jxl_support(self): (self.output_dir / "clip.webm").write_bytes(b"AV1 stream no decoder here") response, body, _jxl = await self._jxl_cover("clip.webm", "image/*,*/*") diff --git a/web/js/workspace/history-media.mjs b/web/js/workspace/history-media.mjs index e43a758a..b72f04a6 100644 --- a/web/js/workspace/history-media.mjs +++ b/web/js/workspace/history-media.mjs @@ -1,4 +1,5 @@ import {projectInspectionPayload} from './inspection-state.mjs'; +import {jxlSupport} from './thumbnail-support.mjs'; const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value); @@ -65,8 +66,10 @@ export function toHistoryInspectionRecord(result, target, extractFacts) { const identity = {kind:'reported-location', key:target.location_key, path:target.reference.path_style === 'windows' ? path.replaceAll('\\', '/') : path, occurrence:{...target.occurrence}, history_epoch:target.history_epoch, current_scope:target.current_scope, reference:{...target.reference}}; const record = projectInspectionPayload(result, identity, {url:urlFor(target, 'url', 'media'), filename:target.filename}, extractFacts); - // #249: the inspector's cover is the recorded output's preview (made by the server on first view). - record.preview = {url:urlFor(target, 'thumbnail_url', 'thumbnail')}; + // #249: the inspector's cover is the recorded output's preview (made by the server on first view). #317: its URL + // names no format, so where this browser decoded JPEG XL it says so (`jxl=1`); the server also reads Accept. + const cover = urlFor(target, 'thumbnail_url', 'thumbnail'); + record.preview = {url:jxlSupport() === true ? `${cover}&jxl=1` : cover}; return record; } export const reportedLocationAdapter = Object.freeze({