@libshub/gif-tools 1.0.3 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @libshub/gif-tools
2
+
3
+ 基于 Canvas 的 React GIF 播放组件,使用 [gifuct-js](https://github.com/matt-way/gifuct-js) 解码,支持播放控制、循环次数、资源缓存与加载性能统计。
4
+
5
+ ## 演示
6
+
7
+ [![GifPlayer 演示](https://static.hanlinbo.cn/%E5%9B%BE%E7%89%87/github/gif-tools-demo.png)](http://www.hanxiaoxin.cn/gif-tools/)
8
+
9
+ 在线体验:[http://www.hanxiaoxin.cn/gif-tools/](http://www.hanxiaoxin.cn/gif-tools/)
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ npm install @libshub/gif-tools
15
+ ```
16
+
17
+ 需要 React 17+:
18
+
19
+ ```bash
20
+ npm install react react-dom
21
+ ```
22
+
23
+ ## 快速开始
24
+
25
+ ```tsx
26
+ import { GifPlayer } from '@libshub/gif-tools'
27
+ import '@libshub/gif-tools/style.css'
28
+
29
+ function App() {
30
+ return (
31
+ <GifPlayer
32
+ src="https://example.com/demo.gif"
33
+ autoPlay
34
+ showControls
35
+ />
36
+ )
37
+ }
38
+ ```
39
+
40
+ ## GifPlayer
41
+
42
+ ### Props
43
+
44
+ | 属性 | 类型 | 默认值 | 说明 |
45
+ |------|------|--------|------|
46
+ | `src` | `string` | — | GIF 地址(必填) |
47
+ | `width` | `number \| string` | — | 画布宽度 |
48
+ | `height` | `number \| string` | — | 画布高度 |
49
+ | `className` | `string` | — | 根节点 class |
50
+ | `style` | `CSSProperties` | — | 根节点样式 |
51
+ | `autoPlay` | `boolean` | `true` | 加载完成后自动播放 |
52
+ | `showControls` | `boolean` | `false` | 显示播放/暂停按钮 |
53
+ | `debug` | `boolean` | `false` | 在画面上叠加加载耗时调试信息 |
54
+ | `loopCount` | `number` | — | 循环次数,不传则按 GIF 自身循环或无限循环 |
55
+ | `onLoaded` | `(stats: GifLoadStats) => void` | — | 加载完成 |
56
+ | `onPlay` | `() => void` | — | 开始播放 |
57
+ | `onPause` | `() => void` | — | 暂停 |
58
+ | `onEnd` | `() => void` | — | 达到循环上限后结束 |
59
+ | `onError` | `(error: Error) => void` | — | 加载或解码失败 |
60
+
61
+ ### Ref 方法
62
+
63
+ 通过 `ref` 可命令式控制播放器:
64
+
65
+ ```tsx
66
+ import { useRef } from 'react'
67
+ import { GifPlayer, type GifPlayerRef } from '@libshub/gif-tools'
68
+
69
+ const ref = useRef<GifPlayerRef>(null)
70
+
71
+ ref.current?.play() // 播放
72
+ ref.current?.pause() // 暂停
73
+ ref.current?.toggle() // 切换播放/暂停
74
+ ref.current?.reset() // 回到第一帧并暂停
75
+ ref.current?.reload() // 重新加载(跳过 pending 复用,强制 fresh)
76
+ ref.current?.isPlaying() // 是否正在播放
77
+ ```
78
+
79
+ ### 完整示例
80
+
81
+ ```tsx
82
+ import { useRef } from 'react'
83
+ import {
84
+ GifPlayer,
85
+ formatGifLoadStats,
86
+ type GifPlayerRef,
87
+ } from '@libshub/gif-tools'
88
+ import '@libshub/gif-tools/style.css'
89
+
90
+ export default function Demo() {
91
+ const playerRef = useRef<GifPlayerRef>(null)
92
+
93
+ return (
94
+ <>
95
+ <button onClick={() => playerRef.current?.toggle()}>播放/暂停</button>
96
+
97
+ <GifPlayer
98
+ ref={playerRef}
99
+ src="https://example.com/demo.gif"
100
+ width="100%"
101
+ height="auto"
102
+ className="my-gif"
103
+ style={{ borderRadius: 8 }}
104
+ autoPlay
105
+ showControls
106
+ loopCount={2}
107
+ debug
108
+ onLoaded={(stats) => console.log(formatGifLoadStats(stats))}
109
+ onPlay={() => console.log('play')}
110
+ onPause={() => console.log('pause')}
111
+ onEnd={() => console.log('end')}
112
+ onError={(e) => console.error(e)}
113
+ />
114
+ </>
115
+ )
116
+ }
117
+ ```
118
+
119
+ ## 资源缓存
120
+
121
+ 相同 `src` 的 GIF 在全局共享解码结果,多个 `GifPlayer` 实例不会重复 fetch / decode。
122
+
123
+ ```tsx
124
+ import { clearGifResourceCache } from '@libshub/gif-tools'
125
+
126
+ // 清空全部缓存
127
+ clearGifResourceCache()
128
+ ```
129
+
130
+ `onLoaded` 回调中的 `GifLoadStats` 可区分三种加载模式:
131
+
132
+ - **fresh**:首次加载,包含 `fetchTimeMs` 与 `decodeTimeMs`
133
+ - **pending**:复用进行中的请求,包含 `pendingWaitFetchMs` 与 `pendingWaitDecodeMs`
134
+ - **cache**:命中缓存,耗时均为 0
135
+
136
+ ```tsx
137
+ import { formatGifLoadStats, getGifLoadStatsView } from '@libshub/gif-tools'
138
+
139
+ onLoaded={(stats) => {
140
+ console.log(formatGifLoadStats(stats))
141
+ // fresh
142
+ // fetch 120.5ms
143
+ // decode 45.2ms
144
+ // total 165.7ms
145
+
146
+ const view = getGifLoadStatsView(stats)
147
+ console.log(view.mode) // 'fresh' | 'pending' | 'cache'
148
+ }}
149
+ ```
150
+
151
+ ## 底层 API
152
+
153
+ 不依赖 React 时,可直接在 Canvas 上创建控制器:
154
+
155
+ ```ts
156
+ import { createGifController } from '@libshub/gif-tools'
157
+
158
+ const canvas = document.querySelector('canvas')!
159
+ const { controller, stats } = await createGifController(canvas, src, {
160
+ loopCount: 3,
161
+ onPlay: () => {},
162
+ onPause: () => {},
163
+ onEnd: () => {},
164
+ })
165
+
166
+ controller.play()
167
+ controller.pause()
168
+ controller.reset()
169
+ controller.isPlaying()
170
+ controller.getCompletedLoops()
171
+ controller.destroy()
172
+ ```
173
+
174
+ ## 导出一览
175
+
176
+ **组件**
177
+
178
+ - `GifPlayer`
179
+
180
+ **类型**
181
+
182
+ - `GifPlayerProps`、`GifPlayerRef`、`GifPlayerComponent`
183
+ - `GifLoadStats`、`GifController`、`CreateGifOptions`
184
+ - `GifLoadStatsView`、`GifLoadStatsLine`、`GifLoadStatsMode`
185
+
186
+ **工具函数**
187
+
188
+ - `createGifController` — 在 Canvas 上创建 GIF 控制器
189
+ - `clearGifResourceCache` — 清空 GIF 资源缓存
190
+ - `formatGifLoadStats` — 格式化加载统计(多行)
191
+ - `formatLoadTimeMs` — 格式化毫秒数
192
+ - `getGifLoadStatsView` — 获取结构化加载统计视图
193
+ - `getTotalLoadTimeMs` — 获取总加载耗时
@@ -0,0 +1,7 @@
1
+ import { type RefObject } from 'react';
2
+ import type { GifLoadStats } from '../utils';
3
+ export declare function GifLoadStatsDebug({ stats, canvasRef, visible, }: {
4
+ stats: GifLoadStats;
5
+ canvasRef: RefObject<HTMLCanvasElement | null>;
6
+ visible: boolean;
7
+ }): import("react").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  import { type CSSProperties, type ForwardRefExoticComponent, type RefAttributes } from 'react';
2
+ import type { GifLoadStats } from '../utils';
2
3
  import './GifPlayer.css';
3
4
  export interface GifPlayerRef {
4
5
  play: () => void;
@@ -17,12 +18,11 @@ export interface GifPlayerProps {
17
18
  autoPlay?: boolean;
18
19
  showControls?: boolean;
19
20
  debug?: boolean;
20
- /** 未设置则无限循环;1 播放一次;N 播放 N 次后触发 onEnd */
21
21
  loopCount?: number;
22
22
  onPlay?: () => void;
23
23
  onPause?: () => void;
24
24
  onEnd?: () => void;
25
- onLoaded?: (decodeTimeMs: number) => void;
25
+ onLoaded?: (stats: GifLoadStats) => void;
26
26
  onError?: (error: Error) => void;
27
27
  }
28
28
  export type GifPlayerComponent = ForwardRefExoticComponent<GifPlayerProps & RefAttributes<GifPlayerRef>>;
@@ -1,2 +1,2 @@
1
- .gif-player{line-height:0;display:inline-block;position:relative;overflow:hidden}.gif-player__media{max-width:100%;height:auto;display:block}.gif-player__controls{opacity:0;gap:6px;transition:opacity .2s;display:flex;position:absolute;bottom:8px;right:8px}.gif-player:hover .gif-player__controls,.gif-player--show-controls .gif-player__controls{opacity:1}.gif-player__btn{color:#fff;cursor:pointer;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#0000008c;border:none;border-radius:50%;justify-content:center;align-items:center;width:32px;height:32px;padding:0;display:flex}.gif-player__btn:hover{background:#000000bf}.gif-player__btn svg{fill:currentColor;width:16px;height:16px}.gif-player__debug{color:#fff;pointer-events:none;background:#000000a6;border-radius:4px;padding:2px 8px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.4;position:absolute;top:8px;left:8px}
1
+ .gif-player{line-height:0;display:inline-block;position:relative;overflow:visible}.gif-player__media{max-width:100%;height:auto;display:block}.gif-player__controls{opacity:0;gap:6px;transition:opacity .2s;display:flex;position:absolute;bottom:8px;right:8px}.gif-player:hover .gif-player__controls,.gif-player--show-controls .gif-player__controls{opacity:1}.gif-player__btn{color:#fff;cursor:pointer;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#0000008c;border:none;border-radius:50%;justify-content:center;align-items:center;width:32px;height:32px;padding:0;display:flex}.gif-player__btn:hover{background:#000000bf}.gif-player__btn svg{fill:currentColor;width:16px;height:16px}.gif-player__debug{z-index:1;pointer-events:none;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);font-variant-numeric:tabular-nums;color:#e8eaed;background:#0c0e12d1;font-family:ui-monospace,Cascadia Code,SF Mono,monospace;position:absolute;top:0;left:0;box-shadow:0 2px 8px #0003}.gif-player__debug--compact{white-space:nowrap;border-radius:0 0 4px;padding:2px 5px;font-size:8px;line-height:1.2}.gif-player__debug--medium{border-radius:0 0 5px;padding:3px 6px;font-size:9px;line-height:1.2}.gif-player__debug--collapsible{pointer-events:auto;cursor:pointer;-webkit-user-select:none;user-select:none}.gif-player__debug--collapsible:hover{background:#12161ceb}.gif-player__debug--collapsible:focus-visible{outline-offset:1px;outline:1px solid #7ec8ffbf}.gif-player__debug--full{border-radius:0 0 6px;min-width:96px;padding:5px 7px;font-size:10px;line-height:1.35}.gif-player__debug--expanded{z-index:2;min-width:108px;max-width:none!important}.gif-player__debug-head{justify-content:space-between;align-items:center;gap:8px;display:flex}.gif-player__debug-badge{letter-spacing:.04em;color:#fff;background:#ffffff24;border-radius:3px;padding:1px 4px;font-size:8px;font-weight:600;display:inline-block}.gif-player__debug--full .gif-player__debug-badge{margin-bottom:4px;padding:1px 5px;font-size:9px}.gif-player__debug[data-mode=fresh] .gif-player__debug-badge{background:#4a90e28c}.gif-player__debug[data-mode=pending] .gif-player__debug-badge{background:#e6a23c8c}.gif-player__debug[data-mode=cache] .gif-player__debug-badge{background:#52c4808c}.gif-player__debug-body{flex-direction:column;gap:2px;display:flex}.gif-player__debug-row,.gif-player__debug-total{justify-content:space-between;align-items:baseline;gap:8px;display:flex}.gif-player__debug-label{color:#e8eaedb8;white-space:nowrap}.gif-player__debug-value{color:#fff;white-space:nowrap}.gif-player__debug-value--total{color:#7ec8ff;font-weight:600}.gif-player__debug[data-mode=cache] .gif-player__debug-value--total{color:#82e0aa}.gif-player__debug-total{border-top:1px solid #ffffff1f;margin-top:4px;padding-top:4px}.gif-player__debug-total .gif-player__debug-label{color:#e8eaede6;font-weight:600}.gif-player__debug-total .gif-player__debug-value{color:#7ec8ff;font-weight:600}.gif-player__debug[data-mode=cache] .gif-player__debug-total .gif-player__debug-value{color:#82e0aa}
2
2
  /*$vite$:1*/
@@ -299,64 +299,122 @@ var c = (e, t) => () => (t || (e((t = { exports: {} }).exports, t), e = null), t
299
299
  });
300
300
  };
301
301
  })))(), h = /* @__PURE__ */ new Map(), g = /* @__PURE__ */ new Map(), _ = /* @__PURE__ */ new Map();
302
- async function v(e) {
303
- let t = await fetch(e, {
302
+ function v(e, t, n) {
303
+ return n === null || e >= n ? {
304
+ pendingWaitFetchMs: 0,
305
+ pendingWaitDecodeMs: t - e
306
+ } : {
307
+ pendingWaitFetchMs: n - e,
308
+ pendingWaitDecodeMs: t - n
309
+ };
310
+ }
311
+ function y() {
312
+ return {
313
+ fetchTimeMs: 0,
314
+ decodeTimeMs: 0,
315
+ pendingWaitFetchMs: 0,
316
+ pendingWaitDecodeMs: 0,
317
+ fromCache: !0,
318
+ fromPending: !1
319
+ };
320
+ }
321
+ function b(e, t) {
322
+ return {
323
+ fetchTimeMs: e,
324
+ decodeTimeMs: t,
325
+ pendingWaitFetchMs: 0,
326
+ pendingWaitDecodeMs: 0,
327
+ fromCache: !1,
328
+ fromPending: !1
329
+ };
330
+ }
331
+ async function x(e, t) {
332
+ let n = performance.now(), r = await fetch(e, {
304
333
  mode: "cors",
305
334
  credentials: "omit"
306
335
  });
307
- if (!t.ok) throw Error(`Failed to load gif: ${t.status}`);
308
- let n = await t.arrayBuffer(), r = performance.now(), i = (0, m.parseGIF)(n), a = (0, m.decompressFrames)(i, !0), o = performance.now() - r;
309
- if (!a.length) throw Error("GIF has no frames");
336
+ if (!r.ok) throw Error(`Failed to load gif: ${r.status}`);
337
+ let i = await r.arrayBuffer(), a = performance.now() - n;
338
+ t && (t.fetchDoneAt = performance.now());
339
+ let o = performance.now(), s = (0, m.parseGIF)(i), c = (0, m.decompressFrames)(s, !0), l = performance.now() - o;
340
+ if (!c.length) throw Error("GIF has no frames");
310
341
  return {
311
342
  gif: {
312
- frames: a,
313
- width: i.lsd.width,
314
- height: i.lsd.height
343
+ frames: c,
344
+ width: s.lsd.width,
345
+ height: s.lsd.height
315
346
  },
316
- decodeTimeMs: o
347
+ fetchTimeMs: a,
348
+ decodeTimeMs: l
317
349
  };
318
350
  }
319
- async function y(e, t) {
351
+ async function S(e, t) {
320
352
  let n = h.get(e);
321
- if (n) return n.refCount += 1, {
353
+ if (n) return {
322
354
  gif: n.data,
323
- decodeTimeMs: 0
355
+ stats: y()
324
356
  };
325
357
  if (!t?.skipPending && g.has(e)) {
326
- let { gif: t } = await g.get(e);
327
- return h.get(e).refCount += 1, {
328
- gif: t,
329
- decodeTimeMs: 0
358
+ let t = g.get(e), n = performance.now(), { gif: r, fetchTimeMs: i, decodeTimeMs: a } = await t.promise, { pendingWaitFetchMs: o, pendingWaitDecodeMs: s } = v(n, performance.now(), t.fetchDoneAt), c = h.get(e);
359
+ return c ||= (h.set(e, {
360
+ data: r,
361
+ refCount: 0,
362
+ fetchTimeMs: i,
363
+ decodeTimeMs: a
364
+ }), h.get(e)), {
365
+ gif: c.data,
366
+ stats: {
367
+ fetchTimeMs: 0,
368
+ decodeTimeMs: 0,
369
+ pendingWaitFetchMs: o,
370
+ pendingWaitDecodeMs: s,
371
+ fromCache: !1,
372
+ fromPending: !0
373
+ }
330
374
  };
331
375
  }
332
376
  let r = (_.get(e) ?? 0) + 1;
333
377
  _.set(e, r);
334
- let i = v(e).then(({ gif: t, decodeTimeMs: n }) => _.get(e) === r ? (g.delete(e), h.set(e, {
378
+ let i = {
379
+ fetchDoneAt: null,
380
+ promise: void 0
381
+ };
382
+ i.promise = x(e, i).then(({ gif: t, fetchTimeMs: n, decodeTimeMs: i }) => _.get(e) === r ? (g.delete(e), h.set(e, {
335
383
  data: t,
336
- refCount: 0
384
+ refCount: 0,
385
+ fetchTimeMs: n,
386
+ decodeTimeMs: i
337
387
  }), {
338
388
  gif: t,
339
- decodeTimeMs: n
389
+ fetchTimeMs: n,
390
+ decodeTimeMs: i
340
391
  }) : {
341
392
  gif: t,
342
- decodeTimeMs: n
393
+ fetchTimeMs: n,
394
+ decodeTimeMs: i
343
395
  }).catch((t) => {
344
396
  throw _.get(e) === r && g.delete(e), t;
345
- });
346
- g.set(e, i);
347
- let { gif: a, decodeTimeMs: o } = await i;
348
- return _.get(e) === r && (h.get(e).refCount += 1), {
397
+ }), g.set(e, i);
398
+ let { gif: a, fetchTimeMs: o, decodeTimeMs: s } = await i.promise;
399
+ return {
349
400
  gif: a,
350
- decodeTimeMs: o
401
+ stats: b(o, s)
351
402
  };
352
403
  }
353
- function b(e) {
404
+ function C(e) {
405
+ let t = h.get(e);
406
+ t && (t.refCount += 1);
407
+ }
408
+ function w(e) {
354
409
  let t = h.get(e);
355
410
  t && (--t.refCount, t.refCount <= 0 && h.delete(e));
356
411
  }
412
+ function T() {
413
+ h.clear(), g.clear(), _.clear();
414
+ }
357
415
  //#endregion
358
416
  //#region src/utils/gifController.ts
359
- function x(e, t, n, r, i = {}) {
417
+ function E(e, t, n, r, i = {}) {
360
418
  let a = e.getContext("2d");
361
419
  if (!a) throw Error("Canvas 2d context unavailable");
362
420
  let o = a;
@@ -400,8 +458,8 @@ function x(e, t, n, r, i = {}) {
400
458
  function x() {
401
459
  b(), u = 0, d = 0, o.clearRect(0, 0, n, r), g(0);
402
460
  }
403
- function S() {
404
- b(), p = null, s.width = 0, s.height = 0, e.width = 0, e.height = 0;
461
+ function S(t) {
462
+ b(), p = null, s.width = 0, s.height = 0, t?.clearCanvas !== !1 && (e.width = 0, e.height = 0);
405
463
  }
406
464
  return g(0), {
407
465
  play: y,
@@ -412,58 +470,252 @@ function x(e, t, n, r, i = {}) {
412
470
  getCompletedLoops: () => d
413
471
  };
414
472
  }
415
- async function S(e, t, n = {}) {
416
- let { skipPending: r, onLoaded: i, ...a } = n, { gif: o, decodeTimeMs: s } = await y(t, { skipPending: r });
417
- i?.(s);
418
- let c = x(e, o.frames, o.width, o.height, a), l = c.destroy;
419
- return c.destroy = () => {
420
- l(), b(t);
421
- }, c;
473
+ async function D(e, t, n = {}) {
474
+ let { skipPending: r, onLoaded: i, ...a } = n, { gif: o, stats: s } = await S(t, { skipPending: r });
475
+ return i?.(s), {
476
+ controller: E(e, o.frames, o.width, o.height, a),
477
+ stats: s
478
+ };
479
+ }
480
+ //#endregion
481
+ //#region src/utils/loadStats.ts
482
+ function O(e) {
483
+ return e.fromCache ? 0 : e.fromPending ? e.pendingWaitFetchMs + e.pendingWaitDecodeMs : e.fetchTimeMs + e.decodeTimeMs;
484
+ }
485
+ function k(e) {
486
+ return e.fromCache ? {
487
+ mode: "cache",
488
+ lines: [{
489
+ label: "wait fetch",
490
+ valueMs: 0
491
+ }, {
492
+ label: "wait decode",
493
+ valueMs: 0
494
+ }],
495
+ totalMs: 0
496
+ } : e.fromPending ? {
497
+ mode: "pending",
498
+ lines: [{
499
+ label: "wait fetch",
500
+ valueMs: e.pendingWaitFetchMs
501
+ }, {
502
+ label: "wait decode",
503
+ valueMs: e.pendingWaitDecodeMs
504
+ }],
505
+ totalMs: e.pendingWaitFetchMs + e.pendingWaitDecodeMs
506
+ } : {
507
+ mode: "fresh",
508
+ lines: [{
509
+ label: "fetch",
510
+ valueMs: e.fetchTimeMs
511
+ }, {
512
+ label: "decode",
513
+ valueMs: e.decodeTimeMs
514
+ }],
515
+ totalMs: e.fetchTimeMs + e.decodeTimeMs
516
+ };
517
+ }
518
+ function A(e) {
519
+ let t = k(e), n = t.lines.map((e) => `${e.label} ${e.valueMs.toFixed(1)}ms`).join("\n");
520
+ return `${t.mode}\n${n}\ntotal ${t.totalMs.toFixed(1)}ms`;
521
+ }
522
+ function j(e) {
523
+ return `${e.toFixed(1)}ms`;
524
+ }
525
+ function M(e, t) {
526
+ let n = Math.min(e, t);
527
+ return n < 130 ? "compact" : n < 220 ? "medium" : "full";
528
+ }
529
+ var N = {
530
+ fresh: "F",
531
+ pending: "P",
532
+ cache: "C"
533
+ };
534
+ function P(e) {
535
+ let t = k(e), n = N[t.mode];
536
+ return t.mode === "cache" ? `${n} · 0` : `${n} · ${j(t.totalMs)}`;
537
+ }
538
+ function F(e, t) {
539
+ return t === "fresh" ? e.label === "fetch" ? "f" : "d" : e.label === "wait fetch" ? "wf" : "wd";
540
+ }
541
+ //#endregion
542
+ //#region src/components/GifLoadStatsDebug.tsx
543
+ var I = {
544
+ fresh: "FRESH",
545
+ pending: "PND",
546
+ cache: "CACHE"
547
+ };
548
+ function L(e, t) {
549
+ let [r, i] = a({
550
+ width: 0,
551
+ height: 0
552
+ });
553
+ return n(() => {
554
+ let n = e.current;
555
+ if (!n || !t) return;
556
+ let r = () => {
557
+ i({
558
+ width: n.clientWidth,
559
+ height: n.clientHeight
560
+ });
561
+ };
562
+ r();
563
+ let a = new ResizeObserver(r);
564
+ return a.observe(n), () => a.disconnect();
565
+ }, [e, t]), r;
566
+ }
567
+ function R({ label: e, valueMs: t }) {
568
+ return /* @__PURE__ */ s("div", {
569
+ className: "gif-player__debug-row",
570
+ children: [/* @__PURE__ */ o("span", {
571
+ className: "gif-player__debug-label",
572
+ children: e
573
+ }), /* @__PURE__ */ o("span", {
574
+ className: "gif-player__debug-value",
575
+ children: j(t)
576
+ })]
577
+ });
578
+ }
579
+ function z({ view: e, width: t, expanded: n, onToggle: r }) {
580
+ return /* @__PURE__ */ s("div", {
581
+ className: [
582
+ "gif-player__debug",
583
+ "gif-player__debug--full",
584
+ n && "gif-player__debug--expanded",
585
+ r && "gif-player__debug--collapsible"
586
+ ].filter(Boolean).join(" "),
587
+ "data-mode": e.mode,
588
+ style: { maxWidth: !n && t > 0 ? Math.min(152, Math.round(t * .52)) : void 0 },
589
+ role: r ? "button" : void 0,
590
+ tabIndex: r ? 0 : void 0,
591
+ title: r ? n ? "点击收起" : "点击展开详情" : void 0,
592
+ onClick: r,
593
+ onKeyDown: r ? (e) => {
594
+ (e.key === "Enter" || e.key === " ") && (e.preventDefault(), r());
595
+ } : void 0,
596
+ children: [
597
+ /* @__PURE__ */ o("span", {
598
+ className: "gif-player__debug-badge",
599
+ children: I[e.mode]
600
+ }),
601
+ /* @__PURE__ */ o("div", {
602
+ className: "gif-player__debug-body",
603
+ children: e.lines.map((t) => /* @__PURE__ */ o(R, {
604
+ label: F(t, e.mode),
605
+ valueMs: t.valueMs
606
+ }, t.label))
607
+ }),
608
+ /* @__PURE__ */ s("div", {
609
+ className: "gif-player__debug-total",
610
+ children: [/* @__PURE__ */ o("span", {
611
+ className: "gif-player__debug-label",
612
+ children: "Σ"
613
+ }), /* @__PURE__ */ o("span", {
614
+ className: "gif-player__debug-value",
615
+ children: j(e.totalMs)
616
+ })]
617
+ })
618
+ ]
619
+ });
620
+ }
621
+ function B({ stats: e, canvasRef: t, visible: r }) {
622
+ let { width: i, height: c } = L(t, r), l = k(e), u = M(i, c), [d, f] = a(!1), p = u !== "full";
623
+ n(() => {
624
+ f(!1);
625
+ }, [e]), n(() => {
626
+ p || f(!1);
627
+ }, [p]);
628
+ let m = () => {
629
+ p && f((e) => !e);
630
+ };
631
+ return p && d ? /* @__PURE__ */ o(z, {
632
+ view: l,
633
+ width: i,
634
+ expanded: !0,
635
+ onToggle: m
636
+ }) : u === "compact" ? /* @__PURE__ */ o("div", {
637
+ className: "gif-player__debug gif-player__debug--compact gif-player__debug--collapsible",
638
+ "data-mode": l.mode,
639
+ role: "button",
640
+ tabIndex: 0,
641
+ title: "点击展开详情",
642
+ onClick: m,
643
+ onKeyDown: (e) => {
644
+ (e.key === "Enter" || e.key === " ") && (e.preventDefault(), m());
645
+ },
646
+ children: P(e)
647
+ }) : u === "medium" ? /* @__PURE__ */ o("div", {
648
+ className: "gif-player__debug gif-player__debug--medium gif-player__debug--collapsible",
649
+ "data-mode": l.mode,
650
+ role: "button",
651
+ tabIndex: 0,
652
+ title: "点击展开详情",
653
+ onClick: m,
654
+ onKeyDown: (e) => {
655
+ (e.key === "Enter" || e.key === " ") && (e.preventDefault(), m());
656
+ },
657
+ children: /* @__PURE__ */ s("div", {
658
+ className: "gif-player__debug-head",
659
+ children: [/* @__PURE__ */ o("span", {
660
+ className: "gif-player__debug-badge",
661
+ children: I[l.mode]
662
+ }), /* @__PURE__ */ o("span", {
663
+ className: "gif-player__debug-value gif-player__debug-value--total",
664
+ children: j(l.totalMs)
665
+ })]
666
+ })
667
+ }) : /* @__PURE__ */ o(z, {
668
+ view: l,
669
+ width: i,
670
+ expanded: !1
671
+ });
422
672
  }
423
673
  //#endregion
424
674
  //#region src/components/GifPlayer.tsx
425
- var C = e(({ src: e, autoPlay: c = !0, showControls: l = !1, debug: u = !1, loopCount: d, className: f, style: p, width: m, height: h, onPlay: g, onPause: _, onEnd: v, onLoaded: y, onError: b }, x) => {
426
- let C = i(null), w = i(null), T = i(g), E = i(_), D = i(v), O = i(y), k = i(b), [A, j] = a(c), [M, N] = a(!1), [P, F] = a(null), [I, L] = a(0), R = i(!1);
427
- T.current = g, E.current = _, D.current = v, O.current = y, k.current = b;
428
- let z = t(() => {
429
- w.current?.play(), j(!0);
430
- }, []), B = t(() => {
431
- w.current?.pause(), j(!1);
432
- }, []), V = t(() => {
433
- w.current?.isPlaying() ? B() : z();
434
- }, [B, z]), H = t(() => {
435
- w.current?.reset(), j(!1);
436
- }, []), U = t(() => {
437
- R.current = !0, L((e) => e + 1);
675
+ var V = e(({ src: e, autoPlay: c = !0, showControls: l = !1, debug: u = !1, loopCount: d, className: f, style: p, width: m, height: h, onPlay: g, onPause: _, onEnd: v, onLoaded: y, onError: b }, x) => {
676
+ let S = i(null), T = i(null), E = i(g), O = i(_), k = i(v), A = i(y), j = i(b), [M, N] = a(c), [P, F] = a(!1), [I, L] = a(null), [R, z] = a(0), V = i(!1), H = i(0);
677
+ E.current = g, O.current = _, k.current = v, A.current = y, j.current = b;
678
+ let U = t(() => {
679
+ T.current?.play(), N(!0);
438
680
  }, []), W = t(() => {
439
- let t = !1, n = C.current;
440
- if (!n) return () => {};
441
- let r = R.current;
442
- return R.current = !1, N(!1), j(!1), F(null), w.current?.destroy(), w.current = null, S(n, e, {
443
- skipPending: r,
681
+ T.current?.pause(), N(!1);
682
+ }, []), G = t(() => {
683
+ T.current?.isPlaying() ? W() : U();
684
+ }, [W, U]), K = t(() => {
685
+ T.current?.reset(), N(!1);
686
+ }, []), q = t(() => {
687
+ V.current = !0, z((e) => e + 1);
688
+ }, []), J = t(() => {
689
+ if (!e) return () => {};
690
+ let t = ++H.current, n = !1, r = S.current;
691
+ if (!r) return () => {};
692
+ let i = V.current;
693
+ return V.current = !1, F(!1), N(!1), L(null), T.current?.destroy(), T.current = null, D(r, e, {
694
+ skipPending: i,
444
695
  loopCount: d,
445
696
  onPlay: () => {
446
- j(!0), T.current?.();
697
+ t === H.current && N(!0), E.current?.();
447
698
  },
448
699
  onPause: () => {
449
- j(!1), E.current?.();
700
+ t === H.current && N(!1), O.current?.();
450
701
  },
451
702
  onEnd: () => {
452
- j(!1), D.current?.();
453
- },
454
- onLoaded: (e) => {
455
- F(e), O.current?.(e);
703
+ t === H.current && N(!1), k.current?.();
456
704
  }
457
- }).then((e) => {
458
- if (t) {
459
- e.destroy();
705
+ }).then(({ controller: r, stats: i }) => {
706
+ if (A.current?.(i), n || t !== H.current) {
707
+ r.destroy({ clearCanvas: !1 });
460
708
  return;
461
709
  }
462
- w.current = e, N(!0), c && (e.play(), j(!0));
710
+ C(e), T.current = r;
711
+ let a = r.destroy.bind(r);
712
+ r.destroy = (t) => {
713
+ a(t), w(e);
714
+ }, F(!0), L(i), c && (r.play(), N(!0));
463
715
  }).catch((e) => {
464
- t || (F(null), k.current?.(e instanceof Error ? e : Error(String(e))));
716
+ t === H.current && L(null), j.current?.(e instanceof Error ? e : Error(String(e)));
465
717
  }), () => {
466
- t = !0, w.current?.destroy(), w.current = null;
718
+ n = !0, t === H.current && (T.current?.destroy(), T.current = null);
467
719
  };
468
720
  }, [
469
721
  e,
@@ -471,19 +723,19 @@ var C = e(({ src: e, autoPlay: c = !0, showControls: l = !1, debug: u = !1, loop
471
723
  c
472
724
  ]);
473
725
  return r(x, () => ({
474
- play: z,
475
- pause: B,
476
- toggle: V,
477
- reset: H,
478
- reload: U,
479
- isPlaying: () => w.current?.isPlaying() ?? !1
726
+ play: U,
727
+ pause: W,
728
+ toggle: G,
729
+ reset: K,
730
+ reload: q,
731
+ isPlaying: () => T.current?.isPlaying() ?? !1
480
732
  }), [
481
- z,
482
- B,
483
- V,
484
- H,
485
- U
486
- ]), n(() => W(), [W, I]), /* @__PURE__ */ s("div", {
733
+ U,
734
+ W,
735
+ G,
736
+ K,
737
+ q
738
+ ]), n(() => J(), [J, R]), /* @__PURE__ */ s("div", {
487
739
  className: [
488
740
  "gif-player",
489
741
  l && "gif-player--show-controls",
@@ -492,26 +744,27 @@ var C = e(({ src: e, autoPlay: c = !0, showControls: l = !1, debug: u = !1, loop
492
744
  style: p,
493
745
  children: [
494
746
  /* @__PURE__ */ o("canvas", {
495
- ref: C,
747
+ ref: S,
496
748
  className: "gif-player__media",
497
749
  role: "img",
498
750
  style: {
499
751
  ...m === void 0 ? {} : { width: m },
500
752
  ...h === void 0 ? {} : { height: h },
501
- ...M ? {} : { visibility: "hidden" }
753
+ ...P ? {} : { visibility: "hidden" }
502
754
  }
503
755
  }),
504
- u && P !== null && /* @__PURE__ */ s("span", {
505
- className: "gif-player__debug",
506
- children: [P.toFixed(1), "ms"]
756
+ u && I !== null && P && /* @__PURE__ */ o(B, {
757
+ stats: I,
758
+ canvasRef: S,
759
+ visible: P
507
760
  }),
508
- l && M && /* @__PURE__ */ o("div", {
761
+ l && P && /* @__PURE__ */ o("div", {
509
762
  className: "gif-player__controls",
510
763
  children: /* @__PURE__ */ o("button", {
511
764
  type: "button",
512
765
  className: "gif-player__btn",
513
- onClick: V,
514
- children: A ? /* @__PURE__ */ s("svg", {
766
+ onClick: G,
767
+ children: M ? /* @__PURE__ */ s("svg", {
515
768
  viewBox: "0 0 24 24",
516
769
  "aria-hidden": "true",
517
770
  children: [/* @__PURE__ */ o("rect", {
@@ -537,6 +790,6 @@ var C = e(({ src: e, autoPlay: c = !0, showControls: l = !1, debug: u = !1, loop
537
790
  ]
538
791
  });
539
792
  });
540
- C.displayName = "GifPlayer";
793
+ V.displayName = "GifPlayer";
541
794
  //#endregion
542
- export { C as GifPlayer, S as createGifController };
795
+ export { V as GifPlayer, T as clearGifResourceCache, D as createGifController, A as formatGifLoadStats, j as formatLoadTimeMs, k as getGifLoadStatsView, O as getTotalLoadTimeMs };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { GifPlayer } from './components/GifPlayer';
2
2
  export type { GifPlayerComponent, GifPlayerProps, GifPlayerRef, } from './components/GifPlayer';
3
- export { createGifController } from './utils';
4
- export type { CreateGifOptions, GifController } from './utils';
3
+ export { createGifController, clearGifResourceCache, formatGifLoadStats, formatLoadTimeMs, getGifLoadStatsView, getTotalLoadTimeMs } from './utils';
4
+ export type { CreateGifOptions, GifController, GifLoadStats, GifLoadStatsLine, GifLoadStatsMode, GifLoadStatsView } from './utils';
@@ -1,2 +1,5 @@
1
- import type { CreateGifOptions, GifController } from './types';
2
- export declare function createGifController(canvas: HTMLCanvasElement, src: string, options?: CreateGifOptions): Promise<GifController>;
1
+ import type { CreateGifOptions, GifController, GifLoadStats } from './types';
2
+ export declare function createGifController(canvas: HTMLCanvasElement, src: string, options?: CreateGifOptions): Promise<{
3
+ controller: GifController;
4
+ stats: GifLoadStats;
5
+ }>;
@@ -1,13 +1,13 @@
1
- import type { LoadedGif } from './types';
1
+ import type { LoadedGif, GifLoadStats } from './types';
2
2
  interface LoadGifResourceOptions {
3
- /** reload 等场景:不复用进行中的请求,直接发起新 fetch */
4
3
  skipPending?: boolean;
5
4
  }
6
5
  interface LoadGifResult {
7
6
  gif: LoadedGif;
8
- decodeTimeMs: number;
7
+ stats: GifLoadStats;
9
8
  }
10
9
  export declare function loadGifResource(src: string, options?: LoadGifResourceOptions): Promise<LoadGifResult>;
10
+ export declare function acquireGifResource(src: string): void;
11
11
  export declare function releaseGifResource(src: string): void;
12
12
  export declare function clearGifResourceCache(): void;
13
13
  export {};
@@ -1,2 +1,5 @@
1
1
  export { createGifController } from './gifController';
2
- export type { CreateGifOptions, GifController } from './types';
2
+ export { acquireGifResource, clearGifResourceCache, releaseGifResource } from './gifResourceManager';
3
+ export { formatGifLoadStats, formatGifLoadStatsCompact, formatLoadTimeMs, getDebugDensity, getGifLoadStatsView, getTotalLoadTimeMs } from './loadStats';
4
+ export type { GifLoadStatsLine, GifLoadStatsMode, GifLoadStatsView } from './loadStats';
5
+ export type { CreateGifOptions, GifController, GifLoadStats } from './types';
@@ -0,0 +1,18 @@
1
+ import type { GifLoadStats } from './types';
2
+ export declare function getTotalLoadTimeMs(stats: GifLoadStats): number;
3
+ export type GifLoadStatsMode = 'fresh' | 'pending' | 'cache';
4
+ export interface GifLoadStatsLine {
5
+ label: string;
6
+ valueMs: number;
7
+ }
8
+ export interface GifLoadStatsView {
9
+ mode: GifLoadStatsMode;
10
+ lines: GifLoadStatsLine[];
11
+ totalMs: number;
12
+ }
13
+ export declare function getGifLoadStatsView(stats: GifLoadStats): GifLoadStatsView;
14
+ export declare function formatGifLoadStats(stats: GifLoadStats): string;
15
+ export declare function formatLoadTimeMs(ms: number): string;
16
+ export declare function getDebugDensity(width: number, height: number): 'compact' | 'medium' | 'full';
17
+ export declare function formatGifLoadStatsCompact(stats: GifLoadStats): string;
18
+ export declare function getGifLoadStatsLineLabel(line: GifLoadStatsLine, mode: GifLoadStatsMode): string;
@@ -4,20 +4,33 @@ export interface LoadedGif {
4
4
  width: number;
5
5
  height: number;
6
6
  }
7
+ export interface GifLoadStats {
8
+ /** 本次实际 fetch 耗时,仅 fresh 发起方有值 */
9
+ fetchTimeMs: number;
10
+ /** 本次实际 decode 耗时,仅 fresh 发起方有值 */
11
+ decodeTimeMs: number;
12
+ /** 等待进行中的 fetch 阶段,仅 pending */
13
+ pendingWaitFetchMs: number;
14
+ /** 等待进行中的 decode 阶段,仅 pending */
15
+ pendingWaitDecodeMs: number;
16
+ fromCache: boolean;
17
+ fromPending: boolean;
18
+ }
7
19
  export interface CreateGifOptions {
8
20
  loopCount?: number;
9
21
  onEnd?: () => void;
10
22
  onPlay?: () => void;
11
23
  onPause?: () => void;
12
- onLoaded?: (decodeTimeMs: number) => void;
13
- /** reload 等场景:不复用进行中的请求,直接发起新 fetch */
24
+ onLoaded?: (stats: GifLoadStats) => void;
14
25
  skipPending?: boolean;
15
26
  }
16
27
  export interface GifController {
17
28
  play: () => void;
18
29
  pause: () => void;
19
30
  reset: () => void;
20
- destroy: () => void;
31
+ destroy: (options?: {
32
+ clearCanvas?: boolean;
33
+ }) => void;
21
34
  isPlaying: () => boolean;
22
35
  getCompletedLoops: () => number;
23
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libshub/gif-tools",
3
- "version": "1.0.3",
3
+ "version": "1.0.8",
4
4
  "description": "",
5
5
  "module": "./dist/gif-tools.es.js",
6
6
  "types": "./dist/index.d.ts",
@@ -27,6 +27,8 @@
27
27
  "scripts": {
28
28
  "dev": "vite",
29
29
  "build": "vite build && tsc -p tsconfig.lib.json",
30
+ "build:example": "vite build --config vite.example.config.ts",
31
+ "preview:example": "vite preview --config vite.example.config.ts",
30
32
  "lint": "eslint .",
31
33
  "preview": "vite preview"
32
34
  },
@@ -53,4 +55,4 @@
53
55
  "dependencies": {
54
56
  "gifuct-js": "^2.1.2"
55
57
  }
56
- }
58
+ }