Skip to content

Commit a2109ba

Browse files
Merge pull request #795 from ahzvenol/dev
feat:support .gif
2 parents f2a62e6 + 4a7872b commit a2109ba

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed

packages/webgal/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"angular-expressions": "^1.4.3",
1616
"axios": "^0.30.2",
1717
"cloudlogjs": "^1.0.9",
18+
"gifuct-js": "^2.1.2",
1819
"i18next": "^22.4.15",
1920
"localforage": "^1.10.0",
2021
"lodash": "^4.17.21",
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { BaseImageResource, Ticker, UPDATE_PRIORITY, settings } from 'pixi.js';
2+
import { parseGIF, decompressFrames } from 'gifuct-js';
3+
4+
export interface GifResourceOptions {
5+
autoLoad?: boolean;
6+
autoPlay?: boolean;
7+
loop?: boolean;
8+
animationSpeed?: number;
9+
fps?: number;
10+
}
11+
12+
interface PrecomputedFrame {
13+
start: number;
14+
end: number;
15+
imageData: ImageData;
16+
}
17+
18+
const findFrame = (frames: PrecomputedFrame[], time: number) => {
19+
let low = 0;
20+
let high = frames.length - 1;
21+
while (low <= high) {
22+
const mid = (low + high) >> 1;
23+
const f = frames[mid];
24+
if (time >= f.start && time < f.end) return f;
25+
if (time < f.start) high = mid - 1;
26+
else low = mid + 1;
27+
}
28+
return undefined;
29+
};
30+
31+
export class GifResource extends BaseImageResource {
32+
public static override test(_src: unknown, ext?: string): boolean {
33+
return (
34+
ext === 'gif' ||
35+
(typeof _src === 'string' && _src.trim().toLowerCase().endsWith('.gif')) ||
36+
(_src instanceof HTMLImageElement && _src.src.toLowerCase().endsWith('.gif'))
37+
);
38+
}
39+
40+
public declare source: HTMLCanvasElement;
41+
42+
public autoPlay: boolean;
43+
public loop: boolean;
44+
public animationSpeed: number;
45+
public readonly fps: number;
46+
public readonly url: string = '';
47+
48+
private _frames: PrecomputedFrame[] = [];
49+
private _currentTime = 0;
50+
private _playing = false;
51+
private _loadPromise: Promise<this> | null = null;
52+
53+
public constructor(src: unknown, options: GifResourceOptions = {}) {
54+
super(document.createElement('canvas'));
55+
if (typeof src === 'string') this.url = src;
56+
else if (src instanceof HTMLImageElement) this.url = src.src;
57+
this.autoPlay = options.autoPlay ?? true;
58+
this.loop = options.loop ?? true;
59+
this.animationSpeed = options.animationSpeed ?? 1;
60+
this.fps = options.fps ?? 30;
61+
62+
if (options.autoLoad !== false) this.load();
63+
}
64+
65+
public override async load(): Promise<this> {
66+
if (this._loadPromise) return this._loadPromise;
67+
68+
this._loadPromise = (async () => {
69+
const res = await settings.ADAPTER.fetch(this.url);
70+
const buffer = await res.arrayBuffer();
71+
72+
if (!buffer?.byteLength) throw new Error('Invalid GIF buffer');
73+
74+
const gif = parseGIF(buffer);
75+
const gifFrames = decompressFrames(gif, true);
76+
if (!gifFrames.length) throw new Error('Invalid GIF file');
77+
78+
const canvas = document.createElement('canvas');
79+
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
80+
const patchCanvas = document.createElement('canvas');
81+
const patchCtx = patchCanvas.getContext('2d')!;
82+
83+
canvas.width = gif.lsd.width;
84+
canvas.height = gif.lsd.height;
85+
86+
let time = 0;
87+
let prevFrame: ImageData | null = null;
88+
const defaultDelay = 1000 / this.fps;
89+
90+
for (const frame of gifFrames) {
91+
const { dims, delay = defaultDelay, disposalType = 2, patch } = frame;
92+
const { width, height, left, top } = dims;
93+
94+
patchCanvas.width = width;
95+
patchCanvas.height = height;
96+
const patchData = new ImageData(new Uint8ClampedArray(patch), width, height);
97+
patchCtx.putImageData(patchData, 0, 0);
98+
99+
ctx.drawImage(patchCanvas, left, top);
100+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
101+
102+
this._frames.push({ start: time, end: (time += delay), imageData });
103+
104+
if (disposalType === 2) ctx.clearRect(0, 0, canvas.width, canvas.height);
105+
else if (disposalType === 3 && prevFrame) ctx.putImageData(prevFrame, 0, 0);
106+
107+
prevFrame = imageData;
108+
}
109+
110+
this.source.width = canvas.width;
111+
this.source.height = canvas.height;
112+
super.update();
113+
114+
if (this.autoPlay) this.play();
115+
116+
return this;
117+
})();
118+
119+
return this._loadPromise;
120+
}
121+
122+
public play(): void {
123+
if (this._playing) return;
124+
this._playing = true;
125+
Ticker.shared.add(this._update, this, UPDATE_PRIORITY.HIGH);
126+
}
127+
128+
public stop(): void {
129+
if (!this._playing) return;
130+
this._playing = false;
131+
Ticker.shared.remove(this._update, this);
132+
}
133+
134+
public override dispose(): void {
135+
this.stop();
136+
super.dispose();
137+
this._frames = [];
138+
this._loadPromise = null;
139+
}
140+
141+
private _update(): void {
142+
if (!this._playing || !this._frames.length) return;
143+
144+
this._currentTime += Ticker.shared.deltaMS * this.animationSpeed;
145+
const frame = findFrame(this._frames, this._currentTime);
146+
147+
if (frame) {
148+
this.source.getContext('2d')!.putImageData(frame.imageData, 0, 0);
149+
super.update();
150+
}
151+
152+
const end = this._frames[this._frames.length - 1].end;
153+
if (this._currentTime > end) {
154+
if (this.loop) this._currentTime %= end;
155+
else this.stop();
156+
}
157+
}
158+
}

packages/webgal/src/Core/controller/stage/pixi/PixiController.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { logger } from '@/Core/util/logger';
1111
import { v4 as uuid } from 'uuid';
1212
import { cloneDeep, isEqual } from 'lodash';
1313
import * as PIXI from 'pixi.js';
14+
import { INSTALLED } from 'pixi.js';
15+
import { GifResource } from './GifResource';
1416

1517
export interface IAnimationObject {
1618
setStartState: Function;
@@ -62,6 +64,8 @@ export interface ILive2DRecord {
6264
// @ts-ignore
6365
window.PIXI = PIXI;
6466

67+
INSTALLED.push(GifResource);
68+
6569
export default class PixiStage {
6670
public static assignTransform<T extends ITransform>(target: T, source?: ITransform) {
6771
if (!source) return;

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3037,6 +3037,13 @@ gh-pages@^4.0.0:
30373037
fs-extra "^8.1.0"
30383038
globby "^6.1.0"
30393039

3040+
gifuct-js@^2.1.2:
3041+
version "2.1.2"
3042+
resolved "https://registry.yarnpkg.com/gifuct-js/-/gifuct-js-2.1.2.tgz#06152437ba30ec914db8398bd838bd0fbc8a6ecd"
3043+
integrity sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==
3044+
dependencies:
3045+
js-binary-schema-parser "^2.0.3"
3046+
30403047
git-up@^8.1.0:
30413048
version "8.1.1"
30423049
resolved "https://registry.yarnpkg.com/git-up/-/git-up-8.1.1.tgz#06262adadb89a4a614d2922d803a0eda054be8c5"
@@ -3658,6 +3665,11 @@ jest-worker@^26.2.1:
36583665
merge-stream "^2.0.0"
36593666
supports-color "^7.0.0"
36603667

3668+
js-binary-schema-parser@^2.0.3:
3669+
version "2.0.3"
3670+
resolved "https://registry.yarnpkg.com/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz#3d7848748e8586e63b34e8911b643f59cfb6396e"
3671+
integrity sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==
3672+
36613673
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
36623674
version "4.0.0"
36633675
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"

0 commit comments

Comments
 (0)