Created
July 9, 2026 18:05
-
-
Save johnloy/41d1f07438b5e38a5000c3a10ae718d8 to your computer and use it in GitHub Desktop.
AVIF player using ImageDecoder API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>AVIF Frame Player</title> | |
| <style> | |
| :root { | |
| --bg: #0b0d10; | |
| --panel: #14171c; | |
| --line: #242931; | |
| --ink: #e7e9ec; | |
| --dim: #8b929c; | |
| --accent: #f2a65a; | |
| --range: #5fd0c0; | |
| --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; | |
| display: flex; | |
| justify-content: center; | |
| padding: 32px 16px 64px; | |
| } | |
| .app { width: 100%; max-width: 640px; } | |
| h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; letter-spacing: 0.2px; } | |
| .sub { color: var(--dim); font-size: 13px; margin: 0 0 20px; } | |
| .banner { | |
| display: none; | |
| background: #2a1c14; | |
| border: 1px solid #5a3a22; | |
| color: #f2c79a; | |
| font-size: 13px; | |
| padding: 10px 12px; | |
| border-radius: 8px; | |
| margin-bottom: 16px; | |
| } | |
| .dropzone { | |
| border: 1px dashed var(--line); | |
| border-radius: 10px; | |
| padding: 14px 18px; | |
| text-align: center; | |
| color: var(--dim); | |
| font-size: 13px; | |
| cursor: pointer; | |
| transition: border-color .15s, color .15s; | |
| } | |
| .dropzone:hover, .dropzone.drag { border-color: var(--accent); color: var(--ink); } | |
| .stage { | |
| margin-top: 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| background: | |
| linear-gradient(45deg, #1c1f24 25%, transparent 25%) 0 0/20px 20px, | |
| linear-gradient(-45deg, #1c1f24 25%, transparent 25%) 0 0/20px 20px, | |
| linear-gradient(45deg, transparent 75%, #1c1f24 75%) 0 0/20px 20px, | |
| linear-gradient(-45deg, transparent 75%, #1c1f24 75%) 0 0/20px 20px, | |
| #14171c; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 200px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| canvas { max-width: 100%; display: block; image-rendering: pixelated; } | |
| .placeholder { | |
| color: var(--dim); font-size: 13px; | |
| display: flex; flex-direction: column; align-items: center; gap: 10px; | |
| } | |
| .spinner { | |
| width: 24px; height: 24px; | |
| border: 2px solid var(--line); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin .7s linear infinite; | |
| display: none; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .scrub { margin-top: 18px; padding: 6px 0; } | |
| .scrub-track { | |
| position: relative; height: 28px; | |
| background: var(--panel); border: 1px solid var(--line); | |
| border-radius: 6px; cursor: pointer; | |
| } | |
| .scrub-fill { | |
| position: absolute; top: 0; left: 0; bottom: 0; | |
| width: 0%; background: #1d2127; | |
| border-radius: 6px 0 0 6px; pointer-events: none; | |
| } | |
| .scrub-range { | |
| position: absolute; top: 0; bottom: 0; | |
| background: rgba(95, 208, 192, 0.18); | |
| border-left: 1px solid var(--range); | |
| border-right: 1px solid var(--range); | |
| pointer-events: none; | |
| } | |
| .scrub-playhead { | |
| position: absolute; top: -3px; bottom: -3px; | |
| width: 2px; background: var(--accent); | |
| transform: translateX(-1px); pointer-events: none; | |
| } | |
| .scrub-handle { | |
| position: absolute; top: -5px; | |
| width: 12px; height: 38px; | |
| transform: translateX(-6px); | |
| cursor: ew-resize; z-index: 2; | |
| } | |
| .scrub-handle::after { | |
| content: ""; position: absolute; | |
| left: 4px; top: 7px; | |
| width: 4px; height: 24px; | |
| border-radius: 2px; background: var(--range); | |
| } | |
| .scrub-handle:hover::after, .scrub-handle.active::after { background: #8be8db; } | |
| .readout { | |
| display: flex; justify-content: space-between; | |
| font-family: var(--mono); font-size: 12px; color: var(--dim); | |
| margin-top: 8px; | |
| } | |
| .controls { | |
| display: flex; flex-wrap: wrap; align-items: center; | |
| gap: 10px; margin-top: 18px; | |
| } | |
| button { | |
| background: var(--panel); border: 1px solid var(--line); | |
| color: var(--ink); padding: 7px 12px; | |
| border-radius: 7px; font-size: 13px; cursor: pointer; | |
| } | |
| button:hover { border-color: var(--accent); } | |
| button:disabled { opacity: .4; cursor: not-allowed; } | |
| button.primary { border-color: var(--accent); color: var(--accent); } | |
| label.check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); } | |
| .rangeRow { | |
| display: flex; align-items: center; gap: 14px; | |
| margin-top: 14px; font-size: 13px; color: var(--dim); | |
| } | |
| .rangeRow label { display: flex; align-items: center; gap: 6px; } | |
| .rangeRow input[type="number"] { | |
| width: 70px; | |
| background: var(--panel); border: 1px solid var(--line); | |
| color: var(--ink); border-radius: 6px; | |
| padding: 5px 7px; | |
| font-family: var(--mono); font-size: 13px; | |
| } | |
| .credit { | |
| margin-top: 14px; font-size: 12px; color: var(--dim); | |
| display: flex; align-items: baseline; gap: 6px; | |
| } | |
| .credit a { color: var(--dim); text-underline-offset: 2px; } | |
| .credit a:hover { color: var(--ink); } | |
| .credit-dot { opacity: .4; } | |
| pre#log { | |
| margin-top: 16px; | |
| background: var(--panel); border: 1px solid var(--line); | |
| border-radius: 8px; padding: 10px 12px; | |
| font-family: var(--mono); font-size: 12px; color: var(--dim); | |
| white-space: pre-wrap; min-height: 20px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <h1>AVIF Frame Player</h1> | |
| <p class="sub">Frame-accurate seeking and range playback via the <code>ImageDecoder</code> API.</p> | |
| <div class="banner" id="banner"></div> | |
| <div class="dropzone" id="dropzone">Drop your own animated AVIF here, or click to choose a file</div> | |
| <input type="file" id="fileInput" accept=".avif,.avifs,image/avif" hidden /> | |
| <div class="stage"> | |
| <canvas id="canvas" hidden></canvas> | |
| <div class="placeholder" id="placeholder"> | |
| <div class="spinner" id="spinner"></div> | |
| <span id="placeholderText">Loading…</span> | |
| </div> | |
| </div> | |
| <div class="scrub"> | |
| <div class="scrub-track" id="scrubTrack"> | |
| <div class="scrub-fill" id="scrubFill"></div> | |
| <div class="scrub-range" id="scrubRange"></div> | |
| <div class="scrub-playhead" id="playhead"></div> | |
| <div class="scrub-handle" id="handleStart" title="Drag to set range start"></div> | |
| <div class="scrub-handle" id="handleEnd" title="Drag to set range end"></div> | |
| </div> | |
| </div> | |
| <div class="readout"> | |
| <span id="frameReadout">frame – / –</span> | |
| <span id="rangeReadout">range – – –</span> | |
| </div> | |
| <div class="controls"> | |
| <button id="stepBack" disabled>⟸ frame</button> | |
| <button id="playBtn" class="primary" disabled>Play</button> | |
| <button id="stepFwd" disabled>frame ⟹</button> | |
| <label class="check"><input type="checkbox" id="loopChk" checked /> loop</label> | |
| <label class="check"><input type="checkbox" id="rangeOnlyChk" /> restrict to range</label> | |
| </div> | |
| <div class="rangeRow"> | |
| <label>start <input type="number" id="startInput" min="0" value="0" disabled /></label> | |
| <label>end <input type="number" id="endInput" min="0" value="0" disabled /></label> | |
| <button id="useCurrent" disabled>use current as start</button> | |
| </div> | |
| <div class="credit" id="credit" style="display:none"> | |
| <span>Default: <em>Twinkle Star</em> by Ryo Hirafuji</span> | |
| <span class="credit-dot">·</span> | |
| <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC-BY 4.0</a> | |
| <span class="credit-dot">·</span> | |
| <a href="https://github.com/link-u/avif-sample-images" target="_blank" rel="noopener">source</a> | |
| </div> | |
| <pre id="log"></pre> | |
| </div> | |
| <script> | |
| // ─── AvifFramePlayer ───────────────────────────────────────────────────────── | |
| class AvifFramePlayer { | |
| static async isSupported() { | |
| if (!('ImageDecoder' in window)) return false; | |
| try { return await ImageDecoder.isTypeSupported('image/avif'); } | |
| catch { return false; } | |
| } | |
| constructor(canvas, { maxCacheSize = 240 } = {}) { | |
| this.canvas = canvas; | |
| this.ctx = canvas.getContext('2d'); | |
| this.maxCacheSize = maxCacheSize; | |
| this.cache = new Map(); | |
| this.decoder = null; | |
| this.frameCount = 0; | |
| this.repetitionCount = 0; | |
| this.currentFrame = 0; | |
| this.rangeStart = 0; | |
| this.rangeEnd = 0; | |
| this.playing = false; | |
| this.loop = true; | |
| this.restrictToRange = false; | |
| this._timer = null; | |
| this._destroyed = false; | |
| this.onFrame = null; | |
| } | |
| async load(arrayBuffer) { | |
| this.pause(); | |
| for (const e of this.cache.values()) e.bitmap.close(); | |
| this.cache.clear(); | |
| if (this.decoder) this.decoder.close(); | |
| this._destroyed = false; | |
| this.decoder = new ImageDecoder({ data: arrayBuffer, type: 'image/avif' }); | |
| await this.decoder.tracks.ready; | |
| for (const track of this.decoder.tracks) { | |
| if (track.animated && !track.selected) { track.selected = true; break; } | |
| } | |
| const track = this.decoder.tracks.selectedTrack; | |
| this.frameCount = track.frameCount; | |
| this.repetitionCount = track.repetitionCount; | |
| this.rangeStart = 0; | |
| this.rangeEnd = this.frameCount - 1; | |
| const first = await this._decodeFrame(0); | |
| this.currentFrame = 0; | |
| this._draw(first.bitmap); | |
| this._prefetch(); | |
| return { | |
| frameCount: this.frameCount, | |
| repetitionCount: this.repetitionCount, | |
| width: this.canvas.width, | |
| height: this.canvas.height, | |
| }; | |
| } | |
| async _decodeFrame(index) { | |
| if (this.cache.has(index)) { | |
| const entry = this.cache.get(index); | |
| this.cache.delete(index); | |
| this.cache.set(index, entry); | |
| return entry; | |
| } | |
| const { image } = await this.decoder.decode({ frameIndex: index }); | |
| const bitmap = await createImageBitmap(image); | |
| const durationMs = image.duration ? image.duration / 1000 : 100; | |
| image.close(); | |
| const entry = { bitmap, durationMs }; | |
| this.cache.set(index, entry); | |
| if (this.cache.size > this.maxCacheSize) { | |
| const oldestKey = this.cache.keys().next().value; | |
| this.cache.get(oldestKey).bitmap.close(); | |
| this.cache.delete(oldestKey); | |
| } | |
| return entry; | |
| } | |
| async _prefetch() { | |
| for (let i = 0; i < this.frameCount; i++) { | |
| if (this._destroyed) return; | |
| if (!this.cache.has(i)) { | |
| try { await this._decodeFrame(i); } catch { return; } | |
| } | |
| } | |
| } | |
| _draw(bitmap) { | |
| if (this.canvas.width !== bitmap.width || this.canvas.height !== bitmap.height) { | |
| this.canvas.width = bitmap.width; | |
| this.canvas.height = bitmap.height; | |
| } | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.ctx.drawImage(bitmap, 0, 0); | |
| } | |
| async seekToFrame(index) { | |
| index = Math.max(0, Math.min(this.frameCount - 1, Math.round(index))); | |
| const entry = await this._decodeFrame(index); | |
| this.currentFrame = index; | |
| this._draw(entry.bitmap); | |
| if (this.onFrame) this.onFrame(index); | |
| return entry; | |
| } | |
| setRange(start, end) { | |
| start = Math.max(0, Math.min(start, this.frameCount - 1)); | |
| end = Math.max(0, Math.min(end, this.frameCount - 1)); | |
| this.rangeStart = Math.min(start, end); | |
| this.rangeEnd = Math.max(start, end); | |
| } | |
| play() { | |
| if (this.playing || this.frameCount === 0) return; | |
| this.playing = true; | |
| const lo = this.restrictToRange ? this.rangeStart : 0; | |
| const hi = this.restrictToRange ? this.rangeEnd : this.frameCount - 1; | |
| if (this.currentFrame < lo || this.currentFrame > hi) this.currentFrame = lo; | |
| this._step(lo, hi); | |
| } | |
| async _step(lo, hi) { | |
| if (!this.playing) return; | |
| const entry = await this.seekToFrame(this.currentFrame); | |
| this._timer = setTimeout(() => { | |
| if (!this.playing) return; | |
| let next = this.currentFrame + 1; | |
| if (next > hi) { | |
| if (!this.loop) { this.pause(); return; } | |
| next = lo; | |
| } | |
| this.currentFrame = next; | |
| this._step(lo, hi); | |
| }, entry.durationMs); | |
| } | |
| pause() { | |
| this.playing = false; | |
| if (this._timer) clearTimeout(this._timer); | |
| } | |
| destroy() { | |
| this._destroyed = true; | |
| this.pause(); | |
| for (const e of this.cache.values()) e.bitmap.close(); | |
| this.cache.clear(); | |
| if (this.decoder) this.decoder.close(); | |
| } | |
| } | |
| // ─── Default asset (inline — no network fetch needed) ──────────────────────── | |
| // "Twinkle Star" animated AVIF sequence — CC-BY 4.0 | |
| // Author: Ryo Hirafuji (@ledyba-z), github.com/link-u/avif-sample-images | |
| const DEFAULT_NAME = 'star-8bpc.avifs (Twinkle Star, CC-BY 4.0)'; | |
| const DEFAULT_B64 = "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"; | |
| function b64ToArrayBuffer(b64) { | |
| const binary = atob(b64); | |
| const bytes = new Uint8Array(binary.length); | |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); | |
| return bytes.buffer; | |
| } | |
| // ─── UI wiring ─────────────────────────────────────────────────────────────── | |
| const $ = id => document.getElementById(id); | |
| const canvas = $('canvas'), placeholder = $('placeholder'); | |
| const spinner = $('spinner'), placeholderText = $('placeholderText'); | |
| const dropzone = $('dropzone'), fileInput = $('fileInput'); | |
| const scrubTrack = $('scrubTrack'), scrubFill = $('scrubFill'), scrubRange = $('scrubRange'); | |
| const playhead = $('playhead'), handleStart = $('handleStart'), handleEnd = $('handleEnd'); | |
| const frameReadout = $('frameReadout'), rangeReadout = $('rangeReadout'); | |
| const playBtn = $('playBtn'), stepBack = $('stepBack'), stepFwd = $('stepFwd'); | |
| const loopChk = $('loopChk'), rangeOnlyChk = $('rangeOnlyChk'); | |
| const startInput = $('startInput'), endInput = $('endInput'), useCurrent = $('useCurrent'); | |
| const logEl = $('log'), banner = $('banner'), credit = $('credit'); | |
| const player = new AvifFramePlayer(canvas); | |
| let frameCount = 0; | |
| function setLog(text) { logEl.textContent = text; } | |
| function refreshBar() { | |
| if (frameCount < 2) return; | |
| const pf = player.currentFrame / (frameCount - 1); | |
| const sf = player.rangeStart / (frameCount - 1); | |
| const ef = player.rangeEnd / (frameCount - 1); | |
| playhead.style.left = (pf * 100) + '%'; | |
| scrubFill.style.width = (pf * 100) + '%'; | |
| scrubRange.style.left = (sf * 100) + '%'; | |
| scrubRange.style.width = ((ef - sf) * 100) + '%'; | |
| handleStart.style.left = (sf * 100) + '%'; | |
| handleEnd.style.left = (ef * 100) + '%'; | |
| frameReadout.textContent = `frame ${player.currentFrame} / ${frameCount - 1}`; | |
| rangeReadout.textContent = `range ${player.rangeStart} – ${player.rangeEnd}`; | |
| } | |
| player.onFrame = () => refreshBar(); | |
| function setControlsEnabled(enabled) { | |
| [stepBack, playBtn, stepFwd, startInput, endInput, useCurrent].forEach(el => el.disabled = !enabled); | |
| } | |
| async function loadArrayBuffer(buf, name, isDefault) { | |
| player.pause(); playBtn.textContent = 'Play'; | |
| canvas.hidden = true; | |
| placeholder.style.display = 'flex'; | |
| spinner.style.display = 'block'; | |
| placeholderText.textContent = 'Decoding…'; | |
| setControlsEnabled(false); | |
| try { | |
| const info = await player.load(buf); | |
| frameCount = info.frameCount; | |
| canvas.hidden = false; | |
| placeholder.style.display = 'none'; | |
| startInput.max = frameCount - 1; | |
| endInput.max = frameCount - 1; | |
| startInput.value = 0; | |
| endInput.value = frameCount - 1; | |
| player.setRange(0, frameCount - 1); | |
| setControlsEnabled(true); | |
| refreshBar(); | |
| credit.style.display = isDefault ? 'flex' : 'none'; | |
| setLog( | |
| `${name}\n` + | |
| `${info.width}×${info.height}px · ${frameCount} frame${frameCount !== 1 ? 's' : ''} · ` + | |
| `repetitionCount: ${info.repetitionCount === Infinity ? '∞' : info.repetitionCount}` | |
| ); | |
| if (isDefault) { player.play(); playBtn.textContent = 'Pause'; } | |
| } catch (err) { | |
| spinner.style.display = 'none'; | |
| placeholderText.textContent = 'Failed to decode'; | |
| canvas.hidden = true; | |
| setLog('Error: ' + err.message); | |
| } | |
| } | |
| async function loadFile(file) { | |
| const buf = await file.arrayBuffer(); | |
| await loadArrayBuffer(buf, file.name, false); | |
| } | |
| // Drop / file pick | |
| dropzone.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); }); | |
| ['dragenter','dragover'].forEach(ev => | |
| dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.add('drag'); })); | |
| ['dragleave','drop'].forEach(ev => | |
| dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.remove('drag'); })); | |
| dropzone.addEventListener('drop', e => { const f = e.dataTransfer.files[0]; if (f) loadFile(f); }); | |
| // Controls | |
| playBtn.addEventListener('click', () => { | |
| if (player.playing) { player.pause(); playBtn.textContent = 'Play'; } | |
| else { player.play(); playBtn.textContent = 'Pause'; } | |
| }); | |
| stepBack.addEventListener('click', () => { player.pause(); playBtn.textContent = 'Play'; player.seekToFrame(player.currentFrame - 1); }); | |
| stepFwd.addEventListener('click', () => { player.pause(); playBtn.textContent = 'Play'; player.seekToFrame(player.currentFrame + 1); }); | |
| loopChk.addEventListener('change', () => { player.loop = loopChk.checked; }); | |
| rangeOnlyChk.addEventListener('change', () => { player.restrictToRange = rangeOnlyChk.checked; }); | |
| // Scrub bar | |
| function frameFromEvent(e) { | |
| const rect = scrubTrack.getBoundingClientRect(); | |
| return Math.round(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) * (frameCount - 1)); | |
| } | |
| let scrubbing = false; | |
| scrubTrack.addEventListener('pointerdown', e => { | |
| if (e.target === handleStart || e.target === handleEnd || frameCount < 2) return; | |
| scrubbing = true; player.pause(); playBtn.textContent = 'Play'; | |
| player.seekToFrame(frameFromEvent(e)); | |
| }); | |
| window.addEventListener('pointermove', e => { if (scrubbing) player.seekToFrame(frameFromEvent(e)); }); | |
| window.addEventListener('pointerup', () => { scrubbing = false; }); | |
| // Range handles | |
| function wireHandle(handle, isStart) { | |
| let dragging = false; | |
| handle.addEventListener('pointerdown', e => { dragging = true; handle.classList.add('active'); e.stopPropagation(); }); | |
| window.addEventListener('pointermove', e => { | |
| if (!dragging || frameCount < 2) return; | |
| const f = frameFromEvent(e); | |
| if (isStart) player.setRange(f, player.rangeEnd); | |
| else player.setRange(player.rangeStart, f); | |
| startInput.value = player.rangeStart; | |
| endInput.value = player.rangeEnd; | |
| refreshBar(); | |
| }); | |
| window.addEventListener('pointerup', () => { dragging = false; handle.classList.remove('active'); }); | |
| } | |
| wireHandle(handleStart, true); | |
| wireHandle(handleEnd, false); | |
| startInput.addEventListener('input', () => { player.setRange(+startInput.value, player.rangeEnd); refreshBar(); }); | |
| endInput.addEventListener('input', () => { player.setRange(player.rangeStart, +endInput.value); refreshBar(); }); | |
| useCurrent.addEventListener('click', () => { | |
| player.setRange(player.currentFrame, Math.max(player.currentFrame, player.rangeEnd)); | |
| startInput.value = player.rangeStart; endInput.value = player.rangeEnd; | |
| refreshBar(); | |
| }); | |
| // Boot | |
| (async () => { | |
| const ok = await AvifFramePlayer.isSupported(); | |
| if (!ok) { | |
| banner.style.display = 'block'; | |
| banner.textContent = 'ImageDecoder for AVIF not available. Requires Chrome/Edge 94+, Firefox 130+ desktop, or Safari 26+.'; | |
| spinner.style.display = 'none'; | |
| placeholderText.textContent = 'ImageDecoder not available'; | |
| return; | |
| } | |
| spinner.style.display = 'block'; | |
| // Decode inline base64 synchronously — no network request, no CORS | |
| const buf = b64ToArrayBuffer(DEFAULT_B64); | |
| await loadArrayBuffer(buf, DEFAULT_NAME, true); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment