diff --git a/src/demo-page.ts b/src/demo-page.ts index 4fd4fd5..c2bf3ca 100644 --- a/src/demo-page.ts +++ b/src/demo-page.ts @@ -65,7 +65,11 @@ export const DEMO_PAGE_HTML = ` .tile { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; + cursor: pointer; transition: border-color 0.15s, transform 0.15s; } + .tile:hover { border-color: var(--accent); transform: translateY(-1px); } + .tile.loading, .tile.error { cursor: default; } + .tile.loading:hover, .tile.error:hover { border-color: var(--border); transform: none; } .tile-header { padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 13px; font-weight: 600; @@ -89,6 +93,125 @@ export const DEMO_PAGE_HTML = ` } .file-size.ok { color: var(--accent-2); } .file-size.high { color: var(--warn); } + + /* Baseline tile — the unmodified source for comparison */ + .baseline-wrap { margin-bottom: 20px; } + .baseline-tile { + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; overflow: hidden; + display: grid; grid-template-columns: 1fr 1fr; + cursor: pointer; transition: border-color 0.15s; + } + .baseline-tile:hover { border-color: var(--accent-2); } + .baseline-tile.loading, .baseline-tile.error { cursor: default; } + .baseline-tile.loading:hover, .baseline-tile.error:hover { border-color: var(--border); } + .baseline-tile .baseline-image { + background: #000; + display: flex; align-items: center; justify-content: center; + min-height: 200px; max-height: 360px; overflow: hidden; + } + .baseline-tile .baseline-image img { + max-width: 100%; max-height: 360px; display: block; object-fit: contain; + } + .baseline-tile .baseline-info { + padding: 18px 20px; + display: flex; flex-direction: column; gap: 10px; + } + .baseline-tile .baseline-label { + color: var(--accent-2); font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.08em; + } + .baseline-tile h2 { + margin: 0; font-size: 16px; font-weight: 600; + } + .baseline-tile .baseline-meta { + margin-top: 6px; + font-family: var(--mono); font-size: 11px; + color: var(--text-dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 6px 16px; + } + .baseline-tile .baseline-meta .row { display: flex; justify-content: space-between; gap: 8px; } + .baseline-tile .baseline-meta strong { color: var(--text); font-weight: 500; } + .baseline-tile .baseline-note { + font-size: 11px; color: var(--text-dim); line-height: 1.5; + border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; + } + .baseline-tile.error .baseline-image { background: #2a1a1a; color: #ff8b8b; font-size: 12px; padding: 16px; text-align: center; } + .baseline-tile.loading .baseline-image::before { + content: "loading…"; color: var(--text-dim); font-size: 12px; + } + @media (max-width: 720px) { + .baseline-tile { grid-template-columns: 1fr; } + } + + /* Modal */ + .modal-backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.85); + display: none; + z-index: 100; + overflow-y: auto; + padding: 24px; + } + .modal-backdrop.open { display: flex; align-items: flex-start; justify-content: center; } + .modal { + background: var(--panel); border: 1px solid var(--border); + border-radius: 12px; + max-width: calc(100vw - 48px); + width: auto; + margin: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + display: flex; flex-direction: column; + } + .modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + gap: 16px; + } + .modal-title { font-size: 15px; font-weight: 600; } + .modal-title .target { color: var(--accent); } + .modal-title .display-info { color: var(--text-dim); font-weight: 400; font-size: 12px; margin-left: 8px; } + .modal-close { + background: var(--panel-2); border: 1px solid var(--border); + color: var(--text); width: 32px; height: 32px; + border-radius: 6px; cursor: pointer; font-size: 18px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .modal-close:hover { background: var(--border); } + .modal-image-wrap { + background: #000; display: flex; align-items: center; justify-content: center; + padding: 0; max-height: calc(100vh - 240px); + overflow: auto; + } + .modal-image-wrap img { + display: block; height: auto; + /* width is set inline by JS to match target (or viewport cap) */ + } + .modal-meta { + padding: 14px 18px; + font-family: var(--mono); font-size: 12px; + color: var(--text-dim); + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px 24px; + border-top: 1px solid var(--border); + } + .modal-meta .row { display: flex; justify-content: space-between; gap: 8px; } + .modal-meta .row strong { color: var(--text); font-weight: 500; } + .modal-explanation { + padding: 14px 18px; + font-size: 12px; + color: var(--text-dim); + border-top: 1px solid var(--border); + line-height: 1.6; + } + .modal-explanation code { + background: var(--panel-2); padding: 1px 5px; + border-radius: 3px; font-size: 11px; + color: var(--text); + } details { margin-top: 24px; } summary { cursor: pointer; color: var(--text-dim); font-size: 13px; padding: 8px 0; } details[open] summary { color: var(--text); } @@ -102,7 +225,9 @@ export const DEMO_PAGE_HTML = ` display: flex; gap: 16px; flex-wrap: wrap; font-size: 11px; color: var(--text-dim); margin-top: 12px; } - .legend span::before { + .legend .target::before, + .legend .source::before, + .legend .equal::before { content: "■"; margin-right: 4px; } .legend .target::before { color: var(--accent); } @@ -126,13 +251,16 @@ export const DEMO_PAGE_HTML = `
@@ -163,12 +291,30 @@ export const DEMO_PAGE_HTML = `
Loading source…
+
+
+ +
target × 1.5 binds (normal case) source × 1.5 binds (tiny-source cap) source equals target + → click any tile to view at target size
@@ -247,6 +393,89 @@ function fileSizeClass(bytes, target) { return 'ok'; } +const baselineWrap = document.getElementById('baseline-wrap'); + +async function loadBaseline(source) { + // Passthrough URL: no options, worker just streams the source bytes through. + // This gives us source dimensions and original file size as a baseline for + // comparing what the transcoded tiles deliver. + const path = '/image/' + source; + const fullUrl = window.location.origin + path; + + // Skeleton + baselineWrap.innerHTML = \` +
+
+
+ Baseline — Original Source +

Loading source…

+
+
+
+ \`; + + const tile = baselineWrap.querySelector('.baseline-tile'); + + try { + const result = await fetchHead(fullUrl); + const h = result.headers; + const contentType = (h['content-type'] || 'image/?').replace('image/', ''); + const cache = h['x-transcode-cache'] || '—'; + const encodeMarker = h['x-transcode-encode'] || ''; + + // For the baseline we need the natural dimensions of the source. The + // passthrough path doesn't set X-Transcode-Source-* headers (because no + // transform was attempted), so we measure from the loaded image instead. + // We'll fill these in once the image loads. + tile.dataset.path = path; + tile.dataset.size = String(result.size); + tile.dataset.cache = cache; + tile.dataset.format = contentType; + tile.setAttribute('role', 'button'); + tile.setAttribute('tabindex', '0'); + tile.setAttribute('aria-label', 'Open original source preview'); + + const img = document.createElement('img'); + img.src = path; + img.alt = 'original source'; + img.onload = () => { + tile.dataset.sourceW = String(img.naturalWidth); + tile.dataset.sourceH = String(img.naturalHeight); + tile.classList.remove('loading'); + const heading = tile.querySelector('h2'); + heading.innerHTML = \`Original source \${img.naturalWidth} × \${img.naturalHeight}\`; + const meta = tile.querySelector('.baseline-meta'); + meta.innerHTML = \` +
dimensions\${img.naturalWidth} × \${img.naturalHeight}
+
format\${contentType}
+
size\${formatBytes(result.size)}
+
delivery\${encodeMarker || 'passthrough'}
+
cache\${cache}
+ \`; + }; + img.onerror = () => { + tile.classList.add('error'); + tile.querySelector('.baseline-image').textContent = 'failed to load source'; + }; + tile.querySelector('.baseline-image').appendChild(img); + + // Add a small note explaining the comparison + const info = tile.querySelector('.baseline-info'); + const note = document.createElement('div'); + note.className = 'baseline-note'; + note.textContent = + 'Served through the proxy without any options applied — the worker ' + + 'streams the origin bytes through unchanged. This is the comparison ' + + 'baseline: every transcoded tile below is what the proxy delivers when ' + + 'asked to transform this source for the corresponding target width.'; + info.appendChild(note); + } catch (err) { + tile.classList.remove('loading'); + tile.classList.add('error'); + tile.querySelector('.baseline-image').textContent = 'failed: ' + err.message; + } +} + async function loadGrid() { const source = currentSource(); const q = qualitySelect.value; @@ -255,6 +484,10 @@ async function loadGrid() { sourceInfo.innerHTML = 'Source: ' + escapeHtml(source) + ''; grid.innerHTML = ''; + // Build and load the baseline tile (source through proxy passthrough — no + // options applied means the worker streams the source unmodified). + loadBaseline(source); + // Build tiles up front so they appear immediately const tiles = TARGETS.map(target => { const tile = document.createElement('div'); @@ -289,6 +522,22 @@ async function loadGrid() { tile.classList.remove('loading'); tile.classList.add(bindingClass(binding)); + // Store metadata for the modal + tile.dataset.target = String(target); + tile.dataset.path = path; + tile.dataset.sourceW = sourceW || ''; + tile.dataset.sourceH = sourceH || ''; + tile.dataset.encodeW = encodeW || ''; + tile.dataset.encodeH = encodeH || ''; + tile.dataset.binding = binding; + tile.dataset.quality = quality || ''; + tile.dataset.format = format; + tile.dataset.cache = cache; + tile.dataset.size = String(result.size); + tile.setAttribute('role', 'button'); + tile.setAttribute('tabindex', '0'); + tile.setAttribute('aria-label', 'Open ' + target + 'px preview'); + const img = document.createElement('img'); img.src = path; img.alt = 'target ' + target; @@ -322,6 +571,185 @@ function escapeHtml(s) { }[c])); } +// Modal +const modalBackdrop = document.getElementById('modal-backdrop'); +const modalTitle = document.getElementById('modal-title'); +const modalImageWrap = document.getElementById('modal-image-wrap'); +const modalMeta = document.getElementById('modal-meta'); +const modalExplanation = document.getElementById('modal-explanation'); +const modalCloseBtn = document.getElementById('modal-close'); + +function openModalForTile(tile) { + if (tile.classList.contains('loading') || tile.classList.contains('error')) return; + const d = tile.dataset; + const target = parseInt(d.target, 10); + const encodeW = parseInt(d.encodeW || '0', 10); + const encodeH = parseInt(d.encodeH || '0', 10); + const sourceW = d.sourceW; + const sourceH = d.sourceH; + const binding = d.binding; + const quality = d.quality; + const format = d.format; + const cache = d.cache; + const size = parseInt(d.size || '0', 10); + const path = d.path; + + // Cap display width to viewport (with a margin for modal padding/scrollbars) + const viewportCap = window.innerWidth - 80; + const displayWidth = Math.min(target, viewportCap); + const isClamped = displayWidth < target; + + modalTitle.innerHTML = + 'target ' + target + 'px' + + '' + + (isClamped + ? 'displayed at ' + displayWidth + 'px (your viewport is narrower than target)' + : 'displayed at ' + target + 'px (1:1 with target)') + + ''; + + // Render the image at target width — the browser does the final downscale + // from the encode dimensions (encodeW × encodeH) to this display size. + // That downscale IS the artifact-filter canon describes. + const img = document.createElement('img'); + img.src = path; + img.alt = 'target ' + target + ' preview'; + img.style.width = displayWidth + 'px'; + modalImageWrap.innerHTML = ''; + modalImageWrap.appendChild(img); + + modalMeta.innerHTML = + '
source' + (sourceW || '?') + ' × ' + (sourceH || '?') + '
' + + '
encode' + (encodeW || '?') + ' × ' + (encodeH || '?') + '
' + + '
display' + displayWidth + 'px wide
' + + '
binds' + (binding || '—') + '
' + + '
qualityq=' + (quality || '?') + '
' + + '
format' + (format || '?') + '
' + + '
size' + formatBytes(size) + '
' + + '
cache' + (cache || '—') + '
'; + + // Explanation tailored to which term bound + let explain = ''; + if (binding === 'target') { + explain = + 'The proxy encoded this image at ' + encodeW + '×' + encodeH + ' ' + + '(target × 1.5, mod-16 aligned). Your browser is downscaling that to ' + displayWidth + 'px ' + + 'for display — that downscale is the artifact filter, the same mechanism canon describes for ' + + '"control the character of the loss."'; + } else if (binding === 'source') { + const scaleVerb = encodeW < displayWidth ? 'upscaling' : encodeW > displayWidth ? 'downscaling' : 'rendering 1:1'; + const scaleClause = encodeW === displayWidth + ? 'and your browser is rendering 1:1 at ' + displayWidth + 'px' + : 'and your browser is ' + scaleVerb + ' from ' + encodeW + 'px to ' + displayWidth + 'px for display'; + explain = + 'Source is small enough that source × 1.5 = ' + encodeW + 'px binds instead of ' + + 'target × 1.5. The proxy encoded at the modest overshoot, ' + scaleClause + '. ' + + 'Without the source × 1.5 cap, this would have manufactured pixels from no signal.'; + } else if (binding === 'equal') { + explain = + 'Source dimensions already match the target. No scaling at the encoder; the only work is ' + + 'format conversion and quality adjustment.'; + } + modalExplanation.innerHTML = explain; + + modalBackdrop.classList.add('open'); + // Focus close button for keyboard accessibility + modalCloseBtn.focus(); +} + +function closeModal() { + modalBackdrop.classList.remove('open'); + // Free the image so it doesn't stay in memory + modalImageWrap.innerHTML = ''; +} + +function openModalForBaseline(tile) { + if (tile.classList.contains('loading') || tile.classList.contains('error')) return; + const d = tile.dataset; + const sourceW = parseInt(d.sourceW || '0', 10); + const sourceH = parseInt(d.sourceH || '0', 10); + const format = d.format; + const cache = d.cache; + const size = parseInt(d.size || '0', 10); + const path = d.path; + + // For the baseline, show the source at its native width, capped to viewport + const viewportCap = window.innerWidth - 80; + const displayWidth = sourceW ? Math.min(sourceW, viewportCap) : viewportCap; + const isClamped = sourceW && displayWidth < sourceW; + + modalTitle.innerHTML = + 'original source' + + '' + + (sourceW + ? (isClamped + ? 'displayed at ' + displayWidth + 'px (your viewport is narrower than source ' + sourceW + 'px)' + : 'displayed at ' + sourceW + 'px (1:1 with source)') + : '') + + ''; + + const img = document.createElement('img'); + img.src = path; + img.alt = 'original source preview'; + img.style.width = displayWidth + 'px'; + modalImageWrap.innerHTML = ''; + modalImageWrap.appendChild(img); + + modalMeta.innerHTML = + '
dimensions' + (sourceW || '?') + ' × ' + (sourceH || '?') + '
' + + '
format' + (format || '?') + '
' + + '
size' + formatBytes(size) + '
' + + '
deliverypassthrough
' + + '
cache' + (cache || '—') + '
'; + + modalExplanation.innerHTML = + 'This is the unmodified source served through the proxy. The worker is in ' + + 'passthrough mode — no w, q, or f options applied, so the bytes ' + + 'are streamed from the origin unchanged. Use these dimensions and file size as ' + + 'the comparison baseline for the transcoded tiles below.'; + + modalBackdrop.classList.add('open'); + modalCloseBtn.focus(); +} + +// Tile click → open modal (regular tiles) +grid.addEventListener('click', (e) => { + const tile = e.target.closest('.tile'); + if (tile) openModalForTile(tile); +}); +grid.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + const tile = e.target.closest('.tile'); + if (tile) { + e.preventDefault(); + openModalForTile(tile); + } + } +}); + +// Baseline tile click → open baseline modal +baselineWrap.addEventListener('click', (e) => { + const tile = e.target.closest('.baseline-tile'); + if (tile) openModalForBaseline(tile); +}); +baselineWrap.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + const tile = e.target.closest('.baseline-tile'); + if (tile) { + e.preventDefault(); + openModalForBaseline(tile); + } + } +}); + +// Close handlers +modalCloseBtn.addEventListener('click', closeModal); +modalBackdrop.addEventListener('click', (e) => { + if (e.target === modalBackdrop) closeModal(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modalBackdrop.classList.contains('open')) closeModal(); +}); + reloadBtn.addEventListener('click', loadGrid); sourceSelect.addEventListener('change', () => { customUrl.value = ''; loadGrid(); }); customUrl.addEventListener('change', loadGrid); diff --git a/src/lib/parse-proxy-path.test.ts b/src/lib/parse-proxy-path.test.ts index 06a5f93..922156a 100644 --- a/src/lib/parse-proxy-path.test.ts +++ b/src/lib/parse-proxy-path.test.ts @@ -41,6 +41,18 @@ describe("parseProxyPath — image", () => { ); }); + test("reattaches search portion when source URL has query string", () => { + // When the browser receives /image/w=800/https://cdn.com/img.jpg?w=2000, + // the URL parser splits pathname (/image/w=800/https://cdn.com/img.jpg) + // from search (?w=2000). The proxy parser must reattach the search to the + // source URL, not lose it. + const result = parseProxyPath( + "/image/w=800/https://cdn.com/img.jpg", + "?w=2000", + ); + expect(result.sourceUrl).toBe("https://cdn.com/img.jpg?w=2000"); + }); + test("preserves source URL with path segments", () => { const result = parseProxyPath( "/image/w=800/https://example.com/path/to/image.jpg", diff --git a/src/lib/parse-proxy-path.ts b/src/lib/parse-proxy-path.ts index a34e748..360a3c1 100644 --- a/src/lib/parse-proxy-path.ts +++ b/src/lib/parse-proxy-path.ts @@ -36,7 +36,10 @@ export class ProxyPathError extends Error { } } -export function parseProxyPath(pathname: string): ParsedRequest { +export function parseProxyPath( + pathname: string, + search: string = "", +): ParsedRequest { // Strip leading slash const trimmed = pathname.startsWith("/") ? pathname.slice(1) : pathname; @@ -65,7 +68,12 @@ export function parseProxyPath(pathname: string): ParsedRequest { throw new ProxyPathError("No source URL found in path"); } - const sourceUrl = rest.slice(urlStart); + // Reattach search/query string to the source URL. The browser splits the + // request URL into pathname+search, but the source URL may legitimately + // contain a query string (e.g. ?w=2000 for Unsplash, signed URLs, etc.). + // The search portion of the request URL is appended to whatever URL we + // extract from the path. + const sourceUrl = rest.slice(urlStart) + search; const optionsSegment = rest.slice(0, urlStart).replace(/\/$/, ""); const options = optionsSegment ? parseOptions(optionsSegment) : {}; diff --git a/src/worker.ts b/src/worker.ts index 22cf6c2..cddb018 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -135,7 +135,8 @@ async function handleImageProxy( ): Promise { let parsed; try { - parsed = parseProxyPath(new URL(request.url).pathname); + const requestUrl = new URL(request.url); + parsed = parseProxyPath(requestUrl.pathname, requestUrl.search); } catch (err) { if (err instanceof ProxyPathError) { return new Response(err.message, { status: err.status }); @@ -287,7 +288,8 @@ async function handleAudioProxy( ): Promise { let parsed; try { - parsed = parseProxyPath(new URL(request.url).pathname); + const requestUrl = new URL(request.url); + parsed = parseProxyPath(requestUrl.pathname, requestUrl.search); } catch (err) { if (err instanceof ProxyPathError) { return new Response(err.message, { status: err.status });