Skip to content

Commit 5f58558

Browse files
committed
feat: let the user download and free the cached engine
1 parent f1b9469 commit 5f58558

6 files changed

Lines changed: 238 additions & 0 deletions

File tree

src/bootstrap/Container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ISignClassifier } from '@domain/recognition/services/ISignClassifi
55
import { IndexedDBCustomSignRepository } from '@infrastructure/persistence/indexeddb/IndexedDBCustomSignRepository';
66
import { HandshapeAlphabetClassifier } from '@infrastructure/recognition/HandshapeAlphabetClassifier';
77
import { PrototypeSignClassifier } from '@infrastructure/recognition/PrototypeSignClassifier';
8+
import { EngineCacheStorage } from '@infrastructure/storage/EngineCacheStorage';
89
import { MediaPipeLandmarkSource } from '@infrastructure/vision/MediaPipeLandmarkSource';
910

1011
/**
@@ -17,6 +18,7 @@ export class Container {
1718
readonly taught = new PrototypeSignClassifier(this.customSigns);
1819
readonly teach = new TeachCustomSignUseCase(this.customSigns);
1920
readonly manageCustomSigns = new ManageCustomSignsUseCase(this.customSigns);
21+
readonly engineStorage = new EngineCacheStorage();
2022
readonly classifiers: readonly ISignClassifier[];
2123
readonly recognize: RecognizeSignsUseCase;
2224

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/** Must match the `runtimeCaching` cacheName in `vite.config.ts`. */
2+
const CACHE_NAME = 'esku-engine';
3+
4+
export interface EngineStorageReport {
5+
readonly cachedBytes: number;
6+
readonly entries: number;
7+
}
8+
9+
/**
10+
* Inspects and frees the cached recognition engine.
11+
*
12+
* The engine is roughly 29 MB of WASM and model weights, cached on first use so the app
13+
* works offline afterwards. That is a real amount of a phone's storage to take without
14+
* asking, and someone who only wanted to try fingerspelling once should be able to get it
15+
* back.
16+
*
17+
* Deliberately does not touch taught signs: those are the user's own recordings, they live
18+
* in IndexedDB, and losing them to a "free up space" button would be a data-loss trap.
19+
*/
20+
export class EngineCacheStorage {
21+
isSupported(): boolean {
22+
return typeof caches !== 'undefined';
23+
}
24+
25+
async report(): Promise<EngineStorageReport> {
26+
if (!this.isSupported()) return { cachedBytes: 0, entries: 0 };
27+
28+
const cache = await caches.open(CACHE_NAME);
29+
const requests = await cache.keys();
30+
31+
let cachedBytes = 0;
32+
for (const request of requests) {
33+
const response = await cache.match(request);
34+
if (response) cachedBytes += await sizeOf(response);
35+
}
36+
37+
return { cachedBytes, entries: requests.length };
38+
}
39+
40+
/** Returns false when there was nothing cached to begin with. */
41+
async clear(): Promise<boolean> {
42+
if (!this.isSupported()) return false;
43+
return caches.delete(CACHE_NAME);
44+
}
45+
}
46+
47+
/**
48+
* Content-Length first, body second.
49+
*
50+
* Reading the blob is exact but pulls the whole 11 MB WASM into memory just to measure it;
51+
* the header avoids that whenever the server sent one, which GitHub Pages does.
52+
*/
53+
async function sizeOf(response: Response): Promise<number> {
54+
const declared = Number(response.headers.get('content-length'));
55+
if (Number.isFinite(declared) && declared > 0) return declared;
56+
57+
try {
58+
return (await response.clone().blob()).size;
59+
} catch {
60+
return 0;
61+
}
62+
}
63+
64+
export function formatBytes(bytes: number): string {
65+
if (bytes <= 0) return '0 MB';
66+
const megabytes = bytes / (1024 * 1024);
67+
return megabytes < 10 ? `${megabytes.toFixed(1)} MB` : `${Math.round(megabytes)} MB`;
68+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { EngineCacheStorage, formatBytes } from '../EngineCacheStorage';
3+
4+
describe('formatBytes', () => {
5+
it('reads a large engine in whole megabytes', () => {
6+
expect(formatBytes(29 * 1024 * 1024)).toBe('29 MB');
7+
});
8+
9+
it('keeps a decimal below ten megabytes, where rounding would hide the difference', () => {
10+
expect(formatBytes(7.5 * 1024 * 1024)).toBe('7.5 MB');
11+
});
12+
13+
it('shows nothing cached as zero rather than a negative or NaN', () => {
14+
expect(formatBytes(0)).toBe('0 MB');
15+
expect(formatBytes(-1)).toBe('0 MB');
16+
});
17+
});
18+
19+
describe('EngineCacheStorage', () => {
20+
it('reports nothing cached where the Cache API is unavailable', async () => {
21+
// jsdom has no CacheStorage, which is also the real situation in a non-secure context.
22+
const storage = new EngineCacheStorage();
23+
expect(storage.isSupported()).toBe(false);
24+
expect(await storage.report()).toEqual({ cachedBytes: 0, entries: 0 });
25+
});
26+
27+
it('reports nothing freed rather than throwing when there is no cache to clear', async () => {
28+
expect(await new EngineCacheStorage().clear()).toBe(false);
29+
});
30+
});

src/presentation/App.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Container } from '@bootstrap/Container';
22
import { CameraUnavailableError } from '@domain/landmarks/services/ILandmarkSource';
33
import { UNSUPPORTED_LETTERS } from '@infrastructure/recognition/packs/lseAlphabet';
44
import { LandmarkOverlay, type OverlayState } from '@presentation/components/LandmarkOverlay';
5+
import { StoragePanel } from '@presentation/components/StoragePanel';
56
import { TeachSignPanel } from '@presentation/components/TeachSignPanel';
67

78
declare const __APP_VERSION__: string;
@@ -36,6 +37,7 @@ export function renderApp(root: HTMLElement): void {
3637
<p class="status" id="status" role="status"></p>
3738
3839
<div id="teach"></div>
40+
<div id="storage"></div>
3941
4042
<section class="card">
4143
<h2 class="card__title">Deletreo, por ahora</h2>
@@ -134,6 +136,15 @@ export function renderApp(root: HTMLElement): void {
134136
render(recognize.current.toText(), []);
135137
});
136138

139+
new StoragePanel(must<HTMLElement>(root, '#storage'), {
140+
isSupported: () => container.engineStorage.isSupported(),
141+
report: () => container.engineStorage.report(),
142+
clear: () => container.engineStorage.clear(),
143+
// Loading through the real source downloads exactly the WASM variant this browser will
144+
// use, rather than guessing and fetching both the SIMD and no-SIMD builds.
145+
preload: () => container.source.load(),
146+
});
147+
137148
new TeachSignPanel(must<HTMLElement>(root, '#teach'), {
138149
captureWindow: () => recognize.captureWindow(),
139150
cancelCapture: () => {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { formatBytes } from '@infrastructure/storage/EngineCacheStorage';
2+
3+
export interface StoragePanelPorts {
4+
isSupported(): boolean;
5+
report(): Promise<{ cachedBytes: number; entries: number }>;
6+
clear(): Promise<boolean>;
7+
/** Fetches the engine now, so the first real use is not the first download. */
8+
preload(): Promise<void>;
9+
}
10+
11+
/**
12+
* Lets the user see and reclaim the space the recognition engine takes.
13+
*
14+
* Downloading is offered up front rather than only happening on first use: knowing it is
15+
* ~29 MB before it starts is the difference between a considered choice and a surprise on
16+
* mobile data.
17+
*/
18+
export class StoragePanel {
19+
private busy = false;
20+
21+
constructor(
22+
private readonly root: HTMLElement,
23+
private readonly ports: StoragePanelPorts,
24+
) {
25+
this.render();
26+
void this.refresh();
27+
}
28+
29+
private render(): void {
30+
this.root.innerHTML = `
31+
<section class="card">
32+
<h2 class="card__title">Espacio en el dispositivo</h2>
33+
<p class="card__body">
34+
El motor de reconocimiento ocupa unos 29 MB y se guarda la primera vez que lo usas,
35+
para que después funcione sin conexión. Puedes descargarlo ahora o liberarlo cuando
36+
quieras: se volverá a bajar solo la próxima vez que enciendas la cámara.
37+
</p>
38+
39+
<p class="storage" id="storage-figure">—</p>
40+
41+
<div class="actions">
42+
<button class="button button--quiet" id="preload" type="button">Descargar ahora</button>
43+
<button class="button button--quiet" id="clear" type="button">Liberar espacio</button>
44+
</div>
45+
46+
<p class="status" id="storage-status" role="status"></p>
47+
<p class="card__body card__body--tight">
48+
Los signos que le hayas enseñado no se borran con esto: son tuyos y se guardan aparte.
49+
</p>
50+
</section>
51+
`;
52+
53+
this.button('preload').addEventListener('click', () => void this.preload());
54+
this.button('clear').addEventListener('click', () => void this.clear());
55+
}
56+
57+
private button(id: string): HTMLButtonElement {
58+
return this.root.querySelector<HTMLButtonElement>(`#${id}`)!;
59+
}
60+
61+
private say(message: string): void {
62+
this.root.querySelector<HTMLElement>('#storage-status')!.textContent = message;
63+
}
64+
65+
private setBusy(busy: boolean): void {
66+
this.busy = busy;
67+
this.button('preload').disabled = busy;
68+
this.button('clear').disabled = busy;
69+
}
70+
71+
private async preload(): Promise<void> {
72+
if (this.busy) return;
73+
this.setBusy(true);
74+
this.say('Descargando el motor…');
75+
try {
76+
await this.ports.preload();
77+
this.say('Motor descargado. Ya funciona sin conexión.');
78+
} catch {
79+
this.say('No se pudo descargar. Comprueba la conexión.');
80+
} finally {
81+
this.setBusy(false);
82+
await this.refresh();
83+
}
84+
}
85+
86+
private async clear(): Promise<void> {
87+
if (this.busy) return;
88+
this.setBusy(true);
89+
try {
90+
const removed = await this.ports.clear();
91+
this.say(
92+
removed
93+
? 'Espacio liberado. Se volverá a descargar la próxima vez.'
94+
: 'No había nada guardado.',
95+
);
96+
} finally {
97+
this.setBusy(false);
98+
await this.refresh();
99+
}
100+
}
101+
102+
private async refresh(): Promise<void> {
103+
const figure = this.root.querySelector<HTMLElement>('#storage-figure')!;
104+
105+
if (!this.ports.isSupported()) {
106+
figure.textContent = 'Este navegador no permite consultar el almacenamiento.';
107+
this.setBusy(true);
108+
return;
109+
}
110+
111+
const { cachedBytes, entries } = await this.ports.report();
112+
figure.textContent =
113+
entries === 0 ? 'Nada guardado todavía' : `${formatBytes(cachedBytes)} guardados`;
114+
}
115+
}

src/presentation/styles/global.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,15 @@ body {
355355
color: var(--text-muted);
356356
font-size: 0.9rem;
357357
}
358+
359+
.storage {
360+
font-size: 1.4rem;
361+
font-weight: 650;
362+
letter-spacing: -0.02em;
363+
margin: 14px 0 4px;
364+
}
365+
366+
.card__body--tight {
367+
font-size: 0.78rem;
368+
margin-top: 10px;
369+
}

0 commit comments

Comments
 (0)