Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e01b7fdc | ||
|
|
2cc9ff0317 | ||
|
|
c1d2d6853f | ||
|
|
42894ff9a1 | ||
|
|
8c8c228ac5 | ||
|
|
e54045d89b | ||
|
|
d6d95171a5 | ||
|
|
4d0a9c3419 | ||
|
|
de620dc826 | ||
|
|
651a3537dd | ||
|
|
f1ae0ab30b |
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IDisposable } from '@xterm/xterm';
|
||||||
|
import type { ImageRenderer } from './ImageRenderer';
|
||||||
|
import { ImageTileInfo, type ImageStorage } from './ImageStorage';
|
||||||
|
import type { IImageSpec, ITerminalExt, IResetHandler } from './Types';
|
||||||
|
import type { IMetrics } from 'IIPMetrics';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note on Firefox Bug (https://bugzilla.mozilla.org/show_bug.cgi?id=2066911)
|
||||||
|
*
|
||||||
|
* ImageDecoder.decode() returns a corrupted VideoFrame,
|
||||||
|
* if the frame was closed from a previous decode.
|
||||||
|
*
|
||||||
|
* Therefore the AnimationManager class implements certain
|
||||||
|
* firefox shims gated by the isFirefox boolean:
|
||||||
|
* - IAnimation.highest tracks highest frameID ever decoded
|
||||||
|
* - whenever frameID is lesser or equal zu highest create new decoder
|
||||||
|
* - raw image bytes need to be held for new decoder
|
||||||
|
*
|
||||||
|
* To be removed once the bug got fixed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
interface IAnimation {
|
||||||
|
/** image id in storage */
|
||||||
|
id: number;
|
||||||
|
/** image bytes */
|
||||||
|
data: Blob;
|
||||||
|
/** metrics of the image */
|
||||||
|
metrics: IMetrics;
|
||||||
|
/** width */
|
||||||
|
w: number;
|
||||||
|
/** height */
|
||||||
|
h: number;
|
||||||
|
/** number of frames */
|
||||||
|
numFrames: number;
|
||||||
|
frames: number[];
|
||||||
|
/** ImageDecoder instance */
|
||||||
|
decoder: ImageDecoder | undefined;
|
||||||
|
decoderPromise: Promise<ImageDecoder> | undefined;
|
||||||
|
cache: BitmapBuffer;
|
||||||
|
/** start timestamp */
|
||||||
|
startTime: number | undefined;
|
||||||
|
/** whether a decode task ist scheduled */
|
||||||
|
scheduled: boolean;
|
||||||
|
/** whether the image is in viewport */
|
||||||
|
inViewport: boolean;
|
||||||
|
/** firefox bug helper */
|
||||||
|
highest: number;
|
||||||
|
/** playtime of animation */
|
||||||
|
playtime: number;
|
||||||
|
/** frame timestamps */
|
||||||
|
timestamps: number[];
|
||||||
|
/** frame ID targeted by animation */
|
||||||
|
current: number;
|
||||||
|
/** frame ID currently visible (owned by spec.actual) */
|
||||||
|
actual: number;
|
||||||
|
/** idx of current on frames */
|
||||||
|
frameIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface IImageDraw {
|
||||||
|
imageId: number;
|
||||||
|
imgSpec: IImageSpec;
|
||||||
|
tileId: number;
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface IBufferSlot {
|
||||||
|
id: number;
|
||||||
|
bm: ImageBitmap | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const enum Constants {
|
||||||
|
BUFFERSIZE = 4,
|
||||||
|
BUFFERMOD = BUFFERSIZE - 1,
|
||||||
|
MAX_FRAMES = 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BitmapBuffer implements IDisposable {
|
||||||
|
private readonly _d: IBufferSlot[];
|
||||||
|
private _disposed = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._d = Array(Constants.BUFFERSIZE).fill(0).map(() => ({ id: -1, bm: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.clear();
|
||||||
|
this._disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public peek(id: number): ImageBitmap | null {
|
||||||
|
const slot = this._d[id & Constants.BUFFERMOD];
|
||||||
|
return id === slot.id ? slot.bm : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pop(id: number): ImageBitmap | null {
|
||||||
|
const slot = this._d[id & Constants.BUFFERMOD];
|
||||||
|
const res = id === slot.id
|
||||||
|
? slot.bm
|
||||||
|
: (slot.bm?.close(), null);
|
||||||
|
slot.bm = null;
|
||||||
|
slot.id = -1;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
public push(id: number, bm: ImageBitmap): void {
|
||||||
|
/**
|
||||||
|
* A late decode task might still push bitmaps.
|
||||||
|
* Since push gives the buffer ownership of
|
||||||
|
* the bitmap, we use the isDisposed flag to
|
||||||
|
* free it right away.
|
||||||
|
*/
|
||||||
|
if (this._disposed) {
|
||||||
|
bm.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slot = this._d[id & Constants.BUFFERMOD];
|
||||||
|
slot.bm?.close();
|
||||||
|
slot.bm = bm;
|
||||||
|
slot.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear(): void {
|
||||||
|
for (let i = 0; i < Constants.BUFFERSIZE; ++i) {
|
||||||
|
const slot = this._d[i];
|
||||||
|
slot.bm?.close();
|
||||||
|
slot.bm = null;
|
||||||
|
slot.id = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
declare const InstallTrigger: any;
|
||||||
|
|
||||||
|
|
||||||
|
// FIXME: move to AnimationManager ctor to control it from xterm side
|
||||||
|
const isChromium = (window as any).chrome !== undefined;
|
||||||
|
const isFirefox: boolean = typeof InstallTrigger !== 'undefined' || 'MozAppearance' in document.documentElement.style;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a read to use ImageDecoder instance from a blob.
|
||||||
|
*/
|
||||||
|
async function BlobDecoder(blob: Blob, type: string): Promise<ImageDecoder> {
|
||||||
|
/**
|
||||||
|
* Dealing with browser bugs - in theory this should be possible:
|
||||||
|
*
|
||||||
|
* const decoder = new ImageDecoder({ data: blob.stream(), type, preferAnimation: true });
|
||||||
|
* await decoder.tracks.ready;
|
||||||
|
* return decoder;
|
||||||
|
*
|
||||||
|
* But this straightforward handling with blob.stream() uncovers several bugs:
|
||||||
|
* 1. Chromium: Fails to stream chunked blobs reliably.
|
||||||
|
* 2. Firefox: Native lifecycle race condition. Closing an old decoder breaks the
|
||||||
|
* shared underlying native stream of the same blob for new decoders, throwing
|
||||||
|
* an uncatchable "Closed decoder" DOMException during `tracks.ready`.
|
||||||
|
*
|
||||||
|
* So we use a safer approach by pulling full raw bytes over first and transferring ownership.
|
||||||
|
*/
|
||||||
|
const data = await blob.arrayBuffer();
|
||||||
|
const decoder = new ImageDecoder({ data, type, preferAnimation: true, transfer: [data] });
|
||||||
|
await decoder.tracks.ready;
|
||||||
|
if (isChromium && type === 'image/avif') {
|
||||||
|
// chromium bug: random frame access in AVIF is
|
||||||
|
// only possible after reading frame 0
|
||||||
|
const res = await decoder.decode({ frameIndex: 0 });
|
||||||
|
res.image.close();
|
||||||
|
}
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnimationManager - orchestrates rendering of animated images.
|
||||||
|
*
|
||||||
|
* Current Workflow
|
||||||
|
* When IIPMetrics.imageType reports an animated image the IIP handler
|
||||||
|
* stores the image in the storage and registers an animation of it.
|
||||||
|
*
|
||||||
|
* The animation manager works mostly self-contained:
|
||||||
|
* - RAF loop
|
||||||
|
* - 3 frame ahead decoding
|
||||||
|
* - own bitmap ownership handling
|
||||||
|
*
|
||||||
|
* There are a few intersections with the ImageStorage and the ImageRenderer,
|
||||||
|
* which still need to be consolidated (TODOs):
|
||||||
|
* - ImageStorage: API for announcing memory state, better eviction strategy
|
||||||
|
* - ImageRenderer: API for better drawing primitives
|
||||||
|
*
|
||||||
|
* In particular the ownership model of ImageSpec bitmaps (actual and orig)
|
||||||
|
* needs a better story. We prolly need here an API to flip bitmaps.
|
||||||
|
*/
|
||||||
|
export class AnimationManager implements IDisposable, IResetHandler {
|
||||||
|
private _animations = new Map<number, IAnimation>();
|
||||||
|
private _animationFrame: number | undefined = undefined;
|
||||||
|
private _draws: IImageDraw[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _storage: ImageStorage,
|
||||||
|
private _renderer: ImageRenderer,
|
||||||
|
private _terminal: ITerminalExt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
|
||||||
|
private _requestAF(cb: FrameRequestCallback): number | undefined {
|
||||||
|
return this._terminal._core._coreBrowserService?.window.requestAnimationFrame(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private _cancelAF(id: number): void | undefined {
|
||||||
|
return this._terminal._core._coreBrowserService?.window.cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset handler.
|
||||||
|
* Stops all animations and frees all ressources.
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
for (const anim of this._animations.values()) {
|
||||||
|
anim.inViewport = false;
|
||||||
|
this._closeDecoder(anim);
|
||||||
|
anim.cache.dispose();
|
||||||
|
}
|
||||||
|
this._animations.clear();
|
||||||
|
this._draws = [];
|
||||||
|
if (this._animationFrame) this._cancelAF(this._animationFrame);
|
||||||
|
this._animationFrame = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main hook to register an animated image for animation.
|
||||||
|
*
|
||||||
|
* @param id Image ID of the image storage the image was registered under.
|
||||||
|
* @param data Raw data bytes of the image.
|
||||||
|
* @param metrics Metrics of the image as returned by IIPMetrics.imageType.
|
||||||
|
* @param w Output width in pixel.
|
||||||
|
* @param h Output height in pixel.
|
||||||
|
*/
|
||||||
|
public async registerAnimation(id: number, data: Blob, metrics: IMetrics, w: number, h: number): Promise<void> {
|
||||||
|
let decoder = await BlobDecoder(data, metrics.mime);
|
||||||
|
const track = decoder.tracks.selectedTrack;
|
||||||
|
if (!track || track.frameCount < 2) {
|
||||||
|
decoder.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameCount = Math.min(track.frameCount, Constants.MAX_FRAMES);
|
||||||
|
const frames: number[] = [];
|
||||||
|
const timestamps: number[] = [];
|
||||||
|
const durations: number[] = [];
|
||||||
|
let elapsed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
try {
|
||||||
|
const result = await decoder.decode({ frameIndex: i });
|
||||||
|
const frame = result.image;
|
||||||
|
const width = frame.displayWidth;
|
||||||
|
const height = frame.displayHeight;
|
||||||
|
const duration = (frame.duration ?? 0) / 1000;
|
||||||
|
frame.close();
|
||||||
|
// skip wrong dimensions and zero duration
|
||||||
|
if (width !== metrics.width || height !== metrics.height || !duration) {
|
||||||
|
if (durations.length) {
|
||||||
|
durations[durations.length - 1] += duration;
|
||||||
|
elapsed += duration;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frames.push(i);
|
||||||
|
timestamps.push(elapsed);
|
||||||
|
durations.push(duration);
|
||||||
|
elapsed += duration;
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIXME: still needs a consolidation of short frame durations:
|
||||||
|
* We don't track v-sync, but just decode the next 3 follow-up frames.
|
||||||
|
* This creates nonsense decoder pressure decoding frames,
|
||||||
|
* that effectively never make it to the screen.
|
||||||
|
* Currently our shortest time resolution is 1ms,
|
||||||
|
* thus with 60 fps we would decode 15 1ms frames for no reason.
|
||||||
|
* We prolly should introduce a lower bound of 8ms:
|
||||||
|
* - in sync with 120 fps
|
||||||
|
* - only every second frame is nonsense with 60 fps
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (frames.length < 2 || elapsed <= 0) {
|
||||||
|
decoder.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirefox) {
|
||||||
|
decoder.close();
|
||||||
|
decoder = await BlobDecoder(data, metrics.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anim: IAnimation = {
|
||||||
|
id, data, w, h, decoder,
|
||||||
|
metrics,
|
||||||
|
numFrames: frames.length,
|
||||||
|
decoderPromise: undefined,
|
||||||
|
cache: new BitmapBuffer(),
|
||||||
|
frames,
|
||||||
|
startTime: undefined,
|
||||||
|
current: 0,
|
||||||
|
actual: 0,
|
||||||
|
scheduled: false, // set by _schedule
|
||||||
|
inViewport: true, // start with active prefetching
|
||||||
|
highest: -1,
|
||||||
|
playtime: elapsed,
|
||||||
|
timestamps,
|
||||||
|
frameIdx: 0
|
||||||
|
};
|
||||||
|
this._animations.set(id, anim);
|
||||||
|
this.viewportUpdated();
|
||||||
|
// early schedule to give decoder time to fill the cache before RAF
|
||||||
|
this._schedule(anim);
|
||||||
|
this._animationFrame ??= this._requestAF(this._loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the animation for the given image id.
|
||||||
|
*/
|
||||||
|
public unregisterAnimation(id: number): void {
|
||||||
|
const a = this._animations.get(id);
|
||||||
|
if (a) {
|
||||||
|
a.inViewport = false;
|
||||||
|
this._closeDecoder(a);
|
||||||
|
a.cache.dispose();
|
||||||
|
this._animations.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onRender handler.
|
||||||
|
* Scratches the viewport buffer to collect draws for animation RAF
|
||||||
|
* and to pause/resume invisible/visible animations.
|
||||||
|
*/
|
||||||
|
public viewportUpdated(_range?: { start: number, end: number }): void {
|
||||||
|
const rows = this._terminal.rows;
|
||||||
|
const cols = this._terminal.cols;
|
||||||
|
const buffer = this._terminal._core.buffer;
|
||||||
|
const actives: Set<number> = new Set();
|
||||||
|
this._draws = [];
|
||||||
|
for (let row = 0; row < rows; ++row) {
|
||||||
|
const line = buffer.lines.get(row + buffer.ydisp);
|
||||||
|
if (!line) break;
|
||||||
|
for (let col = 0; col < cols; ++col) {
|
||||||
|
const e = line.getExtended(col)?.payload;
|
||||||
|
if (e instanceof ImageTileInfo) {
|
||||||
|
const imageId = e.imageId;
|
||||||
|
if (imageId === undefined || imageId === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const imgSpec = this._storage.getImage(imageId);
|
||||||
|
if (e.tileId !== -1) {
|
||||||
|
const startTile = e.tileId;
|
||||||
|
const startCol = col;
|
||||||
|
let count = 1;
|
||||||
|
/**
|
||||||
|
* merge tiles to the right into a single draw call, if:
|
||||||
|
* - not at end of line
|
||||||
|
* - cell has same image id
|
||||||
|
* - cell has consecutive tile id
|
||||||
|
*/
|
||||||
|
while (++col < cols) {
|
||||||
|
const nextE = line.getExtended(col)?.payload;
|
||||||
|
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
col--;
|
||||||
|
if (imgSpec && this._animations.has(imageId)) {
|
||||||
|
this._draws.push({ imageId, imgSpec, tileId: startTile, col: startCol, row, count });
|
||||||
|
actives.add(imageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._updateAnimations(actives);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the frameIdx for elapsed time.
|
||||||
|
* Note: The returned value is not the frame ID,
|
||||||
|
* but the index for IAnimation.frames.
|
||||||
|
*/
|
||||||
|
private _timedFrameIdx(a: IAnimation, elapsed: number): number {
|
||||||
|
elapsed %= a.playtime;
|
||||||
|
let low = 0;
|
||||||
|
let high = a.timestamps.length - 1;
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
const startTime = a.timestamps[mid];
|
||||||
|
if (elapsed < startTime) {
|
||||||
|
high = mid - 1;
|
||||||
|
} else {
|
||||||
|
if (mid === a.timestamps.length - 1 || elapsed < a.timestamps[mid + 1]) {
|
||||||
|
return mid;
|
||||||
|
}
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause/resume decoding from viewport changes.
|
||||||
|
*/
|
||||||
|
private _updateAnimations(visibles: Set<number>): void {
|
||||||
|
for (const [id, anim] of this._animations) {
|
||||||
|
const isVisible = visibles.has(id);
|
||||||
|
if (isVisible && !anim.inViewport) {
|
||||||
|
/**
|
||||||
|
* There are two strategies possible here:
|
||||||
|
*
|
||||||
|
* 1. Resume decoding and pumping frames as soon as possible.
|
||||||
|
* 2. Delay resuming further by a debounce to prevent flooding
|
||||||
|
* new decoder instances on heavy scrolling.
|
||||||
|
*
|
||||||
|
* Currently the code below does 1. as the impact is rather
|
||||||
|
* small from tests and gives the better user experience.
|
||||||
|
*/
|
||||||
|
anim.inViewport = true;
|
||||||
|
const frameIdx = this._timedFrameIdx(anim, performance.now() - anim.startTime!);
|
||||||
|
anim.frameIdx = frameIdx;
|
||||||
|
anim.current = anim.frames[frameIdx];
|
||||||
|
if (!anim.decoder && !anim.decoderPromise) {
|
||||||
|
anim.decoderPromise = BlobDecoder(anim.data, anim.metrics.mime);
|
||||||
|
}
|
||||||
|
this._schedule(anim);
|
||||||
|
} else if (!isVisible && anim.inViewport) {
|
||||||
|
anim.inViewport = false;
|
||||||
|
anim.cache.clear();
|
||||||
|
this._closeDecoder(anim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visibles.size && this._animationFrame === undefined) {
|
||||||
|
this._animationFrame = this._requestAF(this._loop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAF for animations in viewport.
|
||||||
|
*/
|
||||||
|
private _loop = (ts: number): void => {
|
||||||
|
if (!this._animations.size) {
|
||||||
|
this._animationFrame = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mustRender = false;
|
||||||
|
let anyVisible = false;
|
||||||
|
|
||||||
|
for (const anim of this._animations.values()) {
|
||||||
|
if (!anim.inViewport) continue;
|
||||||
|
|
||||||
|
const spec = this._storage.getImage(anim.id);
|
||||||
|
if (!spec) {
|
||||||
|
this.unregisterAnimation(anim.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
anyVisible = true;
|
||||||
|
anim.startTime ??= ts;
|
||||||
|
const frameIdx = this._timedFrameIdx(anim, ts - anim.startTime);
|
||||||
|
anim.frameIdx = frameIdx;
|
||||||
|
const current = anim.frames[frameIdx];
|
||||||
|
|
||||||
|
if (current !== anim.current) {
|
||||||
|
anim.current = current;
|
||||||
|
const bm = anim.cache.pop(current);
|
||||||
|
if (bm) {
|
||||||
|
// FIXME: setting spec.actual delegates draws from onRender back to ImageStorage.render
|
||||||
|
// --> should be avoided to prevent double draws within same AF
|
||||||
|
// idea: create a mutex on image spec to tell storage.render to skip
|
||||||
|
|
||||||
|
// by swapping spec.actual we take ownership, thus have to close it here
|
||||||
|
// but only if we have more than 4 frames (ringbuffer size is 4)
|
||||||
|
// for <= 4 frames we move the bitmap back into cache
|
||||||
|
// FIXME: create a bitmap swapping API in storage
|
||||||
|
if (spec.actual instanceof ImageBitmap) {
|
||||||
|
if (anim.numFrames > Constants.BUFFERSIZE) {
|
||||||
|
spec.actual.close();
|
||||||
|
} else {
|
||||||
|
anim.cache.push(anim.actual, spec.actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec.actual = bm;
|
||||||
|
anim.actual = current;
|
||||||
|
mustRender = true;
|
||||||
|
}
|
||||||
|
this._schedule(anim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: merge clears and draws into col x row rects
|
||||||
|
if (mustRender) {
|
||||||
|
for (let i = 0; i < this._draws.length; ++i) {
|
||||||
|
const d = this._draws[i];
|
||||||
|
this._renderer.clearLines(d.row, d.row);
|
||||||
|
this._renderer.draw(d.imgSpec, d.tileId, d.col, d.row, d.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._animationFrame = anyVisible ? this._requestAF(this._loop) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to schedule frame decoding.
|
||||||
|
*/
|
||||||
|
private _schedule(a: IAnimation): void {
|
||||||
|
if (!a.scheduled) {
|
||||||
|
let needsSchedule = false;
|
||||||
|
// FIXME: do we need a == 4 test too?
|
||||||
|
if (a.numFrames > 3) {
|
||||||
|
needsSchedule =
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]) ||
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 3) % a.numFrames]);
|
||||||
|
} else if (a.numFrames === 3) {
|
||||||
|
needsSchedule =
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]);
|
||||||
|
} else {
|
||||||
|
needsSchedule =
|
||||||
|
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]);
|
||||||
|
}
|
||||||
|
if (needsSchedule) {
|
||||||
|
a.scheduled = true;
|
||||||
|
setTimeout(() => this._decode(a), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode up to next 3 frames.
|
||||||
|
*/
|
||||||
|
private async _decode(a: IAnimation): Promise<void> {
|
||||||
|
if (!a.inViewport) {
|
||||||
|
a.scheduled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!a.decoder) {
|
||||||
|
const decP = a.decoderPromise;
|
||||||
|
a.decoderPromise = undefined;
|
||||||
|
a.decoder = await (decP ?? BlobDecoder(a.data, a.metrics.mime));
|
||||||
|
}
|
||||||
|
const n1 = a.frames[(a.frameIdx + 1) % a.numFrames];
|
||||||
|
const n2 = a.frames[(a.frameIdx + 2) % a.numFrames];
|
||||||
|
const n3 = a.frames[(a.frameIdx + 3) % a.numFrames];
|
||||||
|
for (const n of [n1, n2, n3]) {
|
||||||
|
if (!a.cache.peek(n)) {
|
||||||
|
if (isFirefox) {
|
||||||
|
if (n <= a.highest) {
|
||||||
|
a.highest = -1;
|
||||||
|
a.decoder?.close();
|
||||||
|
a.decoder = await BlobDecoder(a.data, a.metrics.mime);
|
||||||
|
}
|
||||||
|
a.highest = Math.max(n, a.highest);
|
||||||
|
}
|
||||||
|
let res: ImageDecodeResult;
|
||||||
|
try {
|
||||||
|
res = await a.decoder.decode({ frameIndex: n });
|
||||||
|
} catch { continue; }
|
||||||
|
let bm: ImageBitmap;
|
||||||
|
try {
|
||||||
|
// FIXME: we should not resize here yet
|
||||||
|
// BETTER: rescale during draw:
|
||||||
|
// - eases the GPU druing decoding
|
||||||
|
// - rescales only what needs to be drawn
|
||||||
|
// --> funneling down
|
||||||
|
// FIXME: needs changes in image spec and rendering functions
|
||||||
|
bm = await createImageBitmap(res.image, { resizeWidth: a.w, resizeHeight: a.h });
|
||||||
|
} catch { continue; } finally { res?.image?.close(); }
|
||||||
|
if (!a.inViewport) {
|
||||||
|
bm.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
a.cache.push(n, bm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.scheduled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the decoder defensively.
|
||||||
|
*/
|
||||||
|
private _closeDecoder(a: IAnimation): void {
|
||||||
|
const decoder = a.decoder;
|
||||||
|
a.decoder = undefined;
|
||||||
|
try { decoder?.close(); } catch {}
|
||||||
|
const decP = a.decoderPromise;
|
||||||
|
a.decoderPromise = undefined;
|
||||||
|
decP?.then(dec => dec && dec !== decoder ? dec.close() : null).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
import type { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { IIPImageStorage } from './IIPImageStorage';
|
import type { IIPImageStorage } from './IIPImageStorage';
|
||||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||||
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||||
|
import type { AnimationManager } from 'AnimationManager';
|
||||||
|
|
||||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||||
const enum DecoderConst {
|
const enum DecoderConst {
|
||||||
@@ -46,7 +47,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
private readonly _renderer: ImageRenderer,
|
private readonly _renderer: ImageRenderer,
|
||||||
private readonly _storage: IIPImageStorage,
|
private readonly _storage: IIPImageStorage,
|
||||||
private readonly _coreTerminal: ITerminalExt
|
private readonly _coreTerminal: ITerminalExt,
|
||||||
|
private readonly _aniManager: AnimationManager | undefined
|
||||||
) {
|
) {
|
||||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||||
@@ -200,11 +202,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
this._dec.release();
|
this._dec.release();
|
||||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||||
.then(bm => {
|
.then(bm => {
|
||||||
this._storage.addImage(bm);
|
const id = this._storage.addImage(bm);
|
||||||
|
if (this._aniManager && metrics.animated) {
|
||||||
|
this._aniManager.registerAnimation(id, blob as Blob, metrics, w, h).catch(() => {});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
|
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
|
||||||
|
// FIXME: try ImageDecoder here instead (e.g. some AVIF work in Firefox)
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class IIPImageStorage {
|
|||||||
* Add an IIP image to storage.
|
* Add an IIP image to storage.
|
||||||
* Always uses scrolling mode — cursor advances past the image.
|
* Always uses scrolling mode — cursor advances past the image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
||||||
this._storage.addImage(img, this._addImageOpts);
|
return this._storage.addImage(img, this._addImageOpts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,48 +12,48 @@ const fs = require('fs');
|
|||||||
|
|
||||||
|
|
||||||
const TEST_IMAGES: [string, IMetrics][] = [
|
const TEST_IMAGES: [string, IMetrics][] = [
|
||||||
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }],
|
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }],
|
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48, animated: true }],
|
||||||
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
|
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
|
['w3c_home.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||||
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||||
['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
|
['spinfox.png', { mime: 'image/png', width: 148, height: 148, animated: true }],
|
||||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448, animated: false }],
|
||||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600, animated: false }],
|
||||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8, animated: false }],
|
||||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000, animated: false }],
|
||||||
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600, animated: false }],
|
||||||
// VP8
|
// VP8
|
||||||
['1.webp', { mime: 'image/webp', width: 550, height: 368 }],
|
['1.webp', { mime: 'image/webp', width: 550, height: 368, animated: false }],
|
||||||
['2.webp', { mime: 'image/webp', width: 550, height: 404 }],
|
['2.webp', { mime: 'image/webp', width: 550, height: 404, animated: false }],
|
||||||
['3.webp', { mime: 'image/webp', width: 1280, height: 720 }],
|
['3.webp', { mime: 'image/webp', width: 1280, height: 720, animated: false }],
|
||||||
['4.webp', { mime: 'image/webp', width: 1024, height: 772 }],
|
['4.webp', { mime: 'image/webp', width: 1024, height: 772, animated: false }],
|
||||||
['5.webp', { mime: 'image/webp', width: 1024, height: 752 }],
|
['5.webp', { mime: 'image/webp', width: 1024, height: 752, animated: false }],
|
||||||
// VP8X
|
// VP8X
|
||||||
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }],
|
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
|
||||||
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }],
|
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
|
||||||
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }],
|
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
|
||||||
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }],
|
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
|
||||||
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }],
|
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
|
||||||
// VP8L
|
// VP8L
|
||||||
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }],
|
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
|
||||||
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }],
|
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
|
||||||
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }],
|
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
|
||||||
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }],
|
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
|
||||||
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }],
|
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
|
||||||
// some AVIF test images
|
// some AVIF test images
|
||||||
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }],
|
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799, animated: false }],
|
||||||
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }],
|
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048, animated: false }],
|
||||||
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }],
|
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024, animated: false }],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ export interface IMetrics {
|
|||||||
mime: ImageType;
|
mime: ImageType;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
animated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UNSUPPORTED_TYPE: IMetrics = {
|
export const UNSUPPORTED_TYPE: IMetrics = {
|
||||||
mime: 'unsupported',
|
mime: 'unsupported',
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0
|
height: 0,
|
||||||
|
animated: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export function imageType(d: Uint8Array): IMetrics {
|
export function imageType(d: Uint8Array): IMetrics {
|
||||||
@@ -29,28 +31,31 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
return {
|
return {
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
||||||
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
|
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23],
|
||||||
|
animated: isAPNG(d)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// JPEG: FF D8 FF
|
// JPEG: FF D8 FF
|
||||||
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
|
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
|
||||||
const [width, height] = jpgSize(d);
|
const [width, height] = jpgSize(d);
|
||||||
return { mime: 'image/jpeg', width, height };
|
return { mime: 'image/jpeg', width, height, animated: false };
|
||||||
}
|
}
|
||||||
// GIF: GIF87a or GIF89a
|
// GIF: GIF87a or GIF89a
|
||||||
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
|
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
|
||||||
return {
|
return {
|
||||||
mime: 'image/gif',
|
mime: 'image/gif',
|
||||||
width: d[7] << 8 | d[6],
|
width: d[6] | d[7] << 8,
|
||||||
height: d[9] << 8 | d[8]
|
height: d[8] | d[9] << 8,
|
||||||
|
animated: isAGIF(d)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// QOI: qoif
|
// QOI: qoif
|
||||||
if (d32[0] === 0x66696F71) {
|
if (d32[0] === 0x66696F71) {
|
||||||
return {
|
return {
|
||||||
mime: 'image/qoi',
|
mime: 'image/qoi',
|
||||||
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
|
width: d[4] << 24 | d[5] << 16 | d[ 6] << 8 | d[ 7],
|
||||||
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
|
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11],
|
||||||
|
animated: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// WEBP: RIFF | xxxx | WEBP | VP8x
|
// WEBP: RIFF | xxxx | WEBP | VP8x
|
||||||
@@ -60,7 +65,8 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
return {
|
return {
|
||||||
mime: 'image/webp',
|
mime: 'image/webp',
|
||||||
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
|
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
|
||||||
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
|
height: (d[27] | d[28] << 8 | d[29] << 16) + 1,
|
||||||
|
animated: (d[20] & 0x02) !== 0
|
||||||
};
|
};
|
||||||
case 0x4C: // Lossless WebP VP8L --> "L"
|
case 0x4C: // Lossless WebP VP8L --> "L"
|
||||||
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
|
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
|
||||||
@@ -68,14 +74,16 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
return {
|
return {
|
||||||
mime: 'image/webp',
|
mime: 'image/webp',
|
||||||
width: (dim & 0x3FFF) + 1,
|
width: (dim & 0x3FFF) + 1,
|
||||||
height: (dim >>> 14 & 0x3FFF) + 1
|
height: (dim >>> 14 & 0x3FFF) + 1,
|
||||||
|
animated: false
|
||||||
};
|
};
|
||||||
case 0x20: // Lossy WebP VP8 --> " "
|
case 0x20: // Lossy WebP VP8 --> " "
|
||||||
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
|
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
|
||||||
return {
|
return {
|
||||||
mime: 'image/webp',
|
mime: 'image/webp',
|
||||||
width: (d[26] | d[27] << 8) & 0x3FFF,
|
width: (d[26] | d[27] << 8) & 0x3FFF,
|
||||||
height: (d[28] | d[29] << 8) & 0x3FFF
|
height: (d[28] | d[29] << 8) & 0x3FFF,
|
||||||
|
animated: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return UNSUPPORTED_TYPE;
|
return UNSUPPORTED_TYPE;
|
||||||
@@ -83,12 +91,23 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
// AVIF: Box size | ftyp | avif/avis
|
// AVIF: Box size | ftyp | avif/avis
|
||||||
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
|
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
|
||||||
let pos = -1;
|
let pos = -1;
|
||||||
// search for ispe box within first 1024 bytes
|
let anim = d32[2] === 0x73697661; // avis is meant to be animated
|
||||||
|
// search for boxes within first 1024 bytes
|
||||||
const limit = Math.min(d.length - 16, 1024);
|
const limit = Math.min(d.length - 16, 1024);
|
||||||
for (let i = 8; i < limit; i++) {
|
for (let i = 8; i < limit; i++) {
|
||||||
// scan for ispe
|
// scan for ispe for dimensions
|
||||||
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
|
if (pos === -1 && d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
|
||||||
pos = i;
|
pos = i;
|
||||||
|
}
|
||||||
|
// scan for stsz or moov
|
||||||
|
if (!anim) {
|
||||||
|
const isStsz = d[i] === 0x73 && d[i + 1] === 0x74 && d[i + 2] === 0x73 && d[i + 3] === 0x7A;
|
||||||
|
const isMoov = d[i] === 0x6D && d[i + 1] === 0x6F && d[i + 2] === 0x6F && d[i + 3] === 0x76;
|
||||||
|
if (isStsz || isMoov) {
|
||||||
|
anim = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos !== -1 && anim) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +124,7 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
d[pos + 14] << 8 |
|
d[pos + 14] << 8 |
|
||||||
d[pos + 15];
|
d[pos + 15];
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
return { mime: 'image/avif', width, height };
|
return { mime: 'image/avif', width, height, animated: anim };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return UNSUPPORTED_TYPE;
|
return UNSUPPORTED_TYPE;
|
||||||
@@ -118,6 +137,7 @@ function jpgSize(d: Uint8Array): [number, number] {
|
|||||||
const len = d.length;
|
const len = d.length;
|
||||||
let i = 4;
|
let i = 4;
|
||||||
let blockLength = d[i] << 8 | d[i + 1];
|
let blockLength = d[i] << 8 | d[i + 1];
|
||||||
|
if (blockLength < 2) return [0, 0];
|
||||||
while (true) {
|
while (true) {
|
||||||
i += blockLength;
|
i += blockLength;
|
||||||
if (i >= len) {
|
if (i >= len) {
|
||||||
@@ -138,5 +158,78 @@ function jpgSize(d: Uint8Array): [number, number] {
|
|||||||
}
|
}
|
||||||
i += 2;
|
i += 2;
|
||||||
blockLength = d[i] << 8 | d[i + 1];
|
blockLength = d[i] << 8 | d[i + 1];
|
||||||
|
if (blockLength < 2) return [0, 0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isAGIF(data: Uint8Array): boolean {
|
||||||
|
let pos = 10;
|
||||||
|
const gPacked = data[pos];
|
||||||
|
const hasGP = (gPacked & 0x80) !== 0;
|
||||||
|
pos += 3;
|
||||||
|
if (hasGP) {
|
||||||
|
const gpSize = 2 << (gPacked & 0x07);
|
||||||
|
pos += 3 * gpSize;
|
||||||
|
}
|
||||||
|
let gceCount = 0;
|
||||||
|
while (pos < data.length) {
|
||||||
|
const blockType = data[pos];
|
||||||
|
if (blockType === 0x21) { // Extension Block
|
||||||
|
if (pos + 1 >= data.length) return false;
|
||||||
|
const extLabel = data[pos + 1];
|
||||||
|
if (extLabel === 0xF9) { // Graphic Control Extension
|
||||||
|
gceCount++;
|
||||||
|
if (gceCount > 1) return true; // early exit on 2nd frame
|
||||||
|
}
|
||||||
|
pos += 2;
|
||||||
|
while (pos < data.length) {
|
||||||
|
const blockSize = data[pos];
|
||||||
|
if (blockSize === 0x00) break;
|
||||||
|
pos += blockSize + 1;
|
||||||
|
}
|
||||||
|
if (pos >= data.length) return false;
|
||||||
|
pos++;
|
||||||
|
} else if (blockType === 0x2C) { // Image Descriptor
|
||||||
|
if (pos + 10 >= data.length) return false;
|
||||||
|
pos += 10;
|
||||||
|
const lPacked = data[pos - 1];
|
||||||
|
if ((lPacked & 0x80) !== 0) {
|
||||||
|
const lpSize = 2 << (lPacked & 0x07);
|
||||||
|
pos += 3 * lpSize;
|
||||||
|
}
|
||||||
|
if (pos >= data.length) return false;
|
||||||
|
pos++;
|
||||||
|
while (pos < data.length) {
|
||||||
|
const blockSize = data[pos];
|
||||||
|
if (blockSize === 0x00) break;
|
||||||
|
pos += blockSize + 1;
|
||||||
|
}
|
||||||
|
if (pos >= data.length) return false;
|
||||||
|
pos++;
|
||||||
|
} else if (blockType === 0x3B) {
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isAPNG(d: Uint8Array): boolean {
|
||||||
|
let p = 8;
|
||||||
|
while (p + 8 < d.length) {
|
||||||
|
const length = (d[p] << 24 | d[p + 1] << 16 | d[p + 2] << 8 | d[p + 3]) >>> 0;
|
||||||
|
// 'acTL' before 'IDAT': APNG
|
||||||
|
if (d[p + 4] === 0x61 && d[p + 5] === 0x63 && d[p + 6] === 0x54 && d[p + 7] === 0x4c) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 'IDAT' first: no APNG
|
||||||
|
if (d[p + 4] === 0x49 && d[p + 5] === 0x44 && d[p + 6] === 0x41 && d[p + 7] === 0x54) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p += 12 + length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SixelHandler } from './SixelHandler';
|
|||||||
import { SixelImageStorage } from './SixelImageStorage';
|
import { SixelImageStorage } from './SixelImageStorage';
|
||||||
import { IIPImageStorage } from './IIPImageStorage';
|
import { IIPImageStorage } from './IIPImageStorage';
|
||||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||||
|
import { AnimationManager } from './AnimationManager';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +71,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
|
|||||||
iipSupport: true,
|
iipSupport: true,
|
||||||
iipSizeLimit: 33554432,
|
iipSizeLimit: 33554432,
|
||||||
kittySupport: true,
|
kittySupport: true,
|
||||||
kittySizeLimit: 33554432
|
kittySizeLimit: 33554432,
|
||||||
|
animationSupport: true, // alpha
|
||||||
|
animationPixelLimit: 4194304 // limit to 2048 * 2048 pixels
|
||||||
};
|
};
|
||||||
|
|
||||||
// max palette size supported by the sixel lib (compile time setting)
|
// max palette size supported by the sixel lib (compile time setting)
|
||||||
@@ -104,6 +107,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
private _disposables: IDisposable[] = [];
|
private _disposables: IDisposable[] = [];
|
||||||
private _terminal: ITerminalExt | undefined;
|
private _terminal: ITerminalExt | undefined;
|
||||||
private _handlers: Map<String, IResetHandler> = new Map();
|
private _handlers: Map<String, IResetHandler> = new Map();
|
||||||
|
private _aniManager: AnimationManager | undefined;
|
||||||
private readonly _onImageAdded = new Emitter<void>();
|
private readonly _onImageAdded = new Emitter<void>();
|
||||||
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
|
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
|
||||||
|
|
||||||
@@ -144,6 +148,11 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
terminal.options.windowOptions = windowOps;
|
terminal.options.windowOptions = windowOps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._opts.animationSupport && typeof ImageDecoder === 'function') {
|
||||||
|
this._aniManager = new AnimationManager(this._storage, this._renderer, terminal);
|
||||||
|
this._disposeLater(terminal.onRender(range => this._aniManager!.viewportUpdated(range)));
|
||||||
|
}
|
||||||
|
|
||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
this._renderer,
|
this._renderer,
|
||||||
this._storage,
|
this._storage,
|
||||||
@@ -187,7 +196,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
// iTerm IIP handler
|
// iTerm IIP handler
|
||||||
if (this._opts.iipSupport) {
|
if (this._opts.iipSupport) {
|
||||||
const iipStorage = new IIPImageStorage(this._storage!);
|
const iipStorage = new IIPImageStorage(this._storage!);
|
||||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
|
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal, this._aniManager);
|
||||||
this._handlers.set('iip', iipHandler);
|
this._handlers.set('iip', iipHandler);
|
||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||||
@@ -218,6 +227,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
for (const handler of this._handlers.values()) {
|
for (const handler of this._handlers.values()) {
|
||||||
handler.reset();
|
handler.reset();
|
||||||
}
|
}
|
||||||
|
this._aniManager?.reset();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
|
|||||||
height: 14
|
height: 14
|
||||||
};
|
};
|
||||||
|
|
||||||
class ImageTileInfo {
|
export class ImageTileInfo {
|
||||||
constructor(
|
constructor(
|
||||||
public imageId = -1,
|
public imageId = -1,
|
||||||
public tileId = -1) {
|
public tileId = -1) {
|
||||||
@@ -143,6 +143,13 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._fullyCleared = false;
|
this._fullyCleared = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve image spec from storage.
|
||||||
|
*/
|
||||||
|
public getImage(id: number): IImageSpec | undefined {
|
||||||
|
return this._images.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an image by its internal storage ID.
|
* Delete an image by its internal storage ID.
|
||||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface IImageAddonOptions {
|
|||||||
iipSizeLimit: number;
|
iipSizeLimit: number;
|
||||||
kittySupport: boolean;
|
kittySupport: boolean;
|
||||||
kittySizeLimit: number;
|
kittySizeLimit: number;
|
||||||
|
animationSupport: boolean;
|
||||||
|
animationPixelLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IResetHandler {
|
export interface IResetHandler {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface IImageAddonOptions {
|
|||||||
iipSizeLimit: number;
|
iipSizeLimit: number;
|
||||||
kittySupport: boolean;
|
kittySupport: boolean;
|
||||||
kittySizeLimit: number;
|
kittySizeLimit: number;
|
||||||
|
animationSupport: boolean;
|
||||||
|
animationPixelLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
@@ -138,7 +140,9 @@ test.describe('ImageAddon', () => {
|
|||||||
iipSupport: true,
|
iipSupport: true,
|
||||||
iipSizeLimit: 33554432,
|
iipSizeLimit: 33554432,
|
||||||
kittySupport: true,
|
kittySupport: true,
|
||||||
kittySizeLimit: 33554432
|
kittySizeLimit: 33554432,
|
||||||
|
animationSupport: true,
|
||||||
|
animationPixelLimit: 4194304
|
||||||
};
|
};
|
||||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||||
});
|
});
|
||||||
@@ -155,7 +159,9 @@ test.describe('ImageAddon', () => {
|
|||||||
iipSupport: false,
|
iipSupport: false,
|
||||||
iipSizeLimit: 1000,
|
iipSizeLimit: 1000,
|
||||||
kittySupport: false,
|
kittySupport: false,
|
||||||
kittySizeLimit: 1000
|
kittySizeLimit: 1000,
|
||||||
|
animationSupport: false,
|
||||||
|
animationPixelLimit: 1000
|
||||||
};
|
};
|
||||||
await ctx.page.evaluate(opts => {
|
await ctx.page.evaluate(opts => {
|
||||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||||
|
|||||||
Reference in New Issue
Block a user