Skip to content

Commit 7740af4

Browse files
authored
Efficiency improvements (#36)
* enable message compression * convert all image to jpeg
1 parent 49266b5 commit 7740af4

4 files changed

Lines changed: 107 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# 0.2.0
2+
3+
This is a breaking change and requires the backend with version 0.2.x.
4+
5+
## What's new
6+
7+
* **[Breaking]** Add adaptive application level message compression.
8+
* Convert all images to jpeg for better efficiency and compatibility.
9+
10+
## Fixes
11+
12+
## Changes
13+
114
# 0.1.7
215

316
## What's new

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tinywebui-webapp",
3-
"version": "0.1.7",
3+
"version": "0.2.0",
44
"private": true,
55
"type": "module",
66
"scripts": {
@@ -10,6 +10,7 @@
1010
"test": "node --experimental-vm-modules node_modules/.bin/jest"
1111
},
1212
"dependencies": {
13+
"@hpcc-js/wasm-zstd": "^1.11.0",
1314
"@radix-ui/react-select": "^2.2.5",
1415
"@radix-ui/react-slot": "^1.2.3",
1516
"class-variance-authority": "^0.7.1",

src/app/chat/user-input.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,54 @@ import React from "react";
77
import * as ServerTypes from "@/sdk/types/IServer";
88
import { cn } from "@/lib/utils";
99

10+
const readBlobAsDataUrl = (blob: Blob) => new Promise<string>((resolve, reject) => {
11+
const reader = new FileReader();
12+
reader.onload = () => resolve(reader.result as string);
13+
reader.onerror = () => reject(reader.error);
14+
reader.readAsDataURL(blob);
15+
});
16+
17+
const reencodeImageToJpeg = (file: File) => new Promise<string>((resolve, reject) => {
18+
const objectUrl = URL.createObjectURL(file);
19+
const img = new Image();
20+
img.onload = () => {
21+
try {
22+
const canvas = document.createElement("canvas");
23+
canvas.width = img.naturalWidth || img.width;
24+
canvas.height = img.naturalHeight || img.height;
25+
const ctx = canvas.getContext("2d");
26+
if (!ctx) {
27+
reject(new Error("Canvas context unavailable"));
28+
return;
29+
}
30+
ctx.drawImage(img, 0, 0);
31+
canvas.toBlob((blob) => {
32+
URL.revokeObjectURL(objectUrl);
33+
if (!blob) {
34+
reject(new Error("Failed to encode JPEG"));
35+
return;
36+
}
37+
readBlobAsDataUrl(blob).then(resolve).catch(reject);
38+
}, "image/jpeg", 0.92);
39+
} catch (err) {
40+
URL.revokeObjectURL(objectUrl);
41+
reject(err);
42+
}
43+
};
44+
img.onerror = () => {
45+
URL.revokeObjectURL(objectUrl);
46+
reject(new Error("Failed to load pasted image"));
47+
};
48+
img.src = objectUrl;
49+
});
50+
51+
const ensureJpegDataUrl = (file: File) => {
52+
if (file.type === "image/jpeg") {
53+
return readBlobAsDataUrl(file);
54+
}
55+
return reencodeImageToJpeg(file).catch(() => readBlobAsDataUrl(file));
56+
};
57+
1058
interface UserInputProps {
1159
onUserMessage: (message: ServerTypes.Message) => void;
1260
/** This controls the send button. Not the editor. */
@@ -167,12 +215,7 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage, editorH
167215
if (item.kind === "file" && item.type.startsWith("image/")) {
168216
const file = item.getAsFile();
169217
if (!file) continue;
170-
filePromises.push(new Promise((resolve, reject) => {
171-
const reader = new FileReader();
172-
reader.onload = () => resolve(reader.result as string);
173-
reader.onerror = () => reject(reader.error);
174-
reader.readAsDataURL(file);
175-
}));
218+
filePromises.push(ensureJpegDataUrl(file));
176219
}
177220
}
178221
if (filePromises.length > 0) {

src/sdk/session/secure-session.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@ import { HandshakeMessage, HandshakeMessageType } from "../cipher/handshake-mess
77
import * as IServer from "../types/IServer";
88
import { isSecureContext } from "./secure-context";
99
import { sodium } from "../cipher/sodium";
10+
import { Zstd } from "@hpcc-js/wasm-zstd";
11+
12+
const zstd = await Zstd.load();
1013

1114
enum ProtocolType {
1215
Password = 0,
1316
Psk = 1,
1417
};
1518

19+
enum CompressionType {
20+
None =0,
21+
Zstd = 1,
22+
};
23+
1624
export class Connection extends IConnection {
1725
#connection: IConnection;
1826
#username: string | undefined;
@@ -123,6 +131,7 @@ export class Connection extends IConnection {
123131
}
124132

125133
send(data: Uint8Array): void {
134+
data = this.#compressData(data);
126135
if (!this.#turnOffEncryption) {
127136
if (this.#encryptor === undefined) {
128137
throw new Error("Encryption not established");
@@ -143,7 +152,41 @@ export class Connection extends IConnection {
143152
}
144153
data = this.#decryptor.decrypt(data);
145154
}
155+
data = this.#decompressData(data);
146156
return data;
147157
}
158+
159+
#compressData(data: Uint8Array): Uint8Array {
160+
if (data.length < 100) {
161+
/** Not worth it */
162+
return this.#formCompressedData(data, CompressionType.None);
163+
}
164+
const compressed = zstd.compress(data, 3);
165+
if (compressed.length + 1 >= data.length) {
166+
/** Compression not effective */
167+
return this.#formCompressedData(data, CompressionType.None);
168+
}
169+
return this.#formCompressedData(compressed, CompressionType.Zstd);
170+
}
171+
172+
#formCompressedData(data: Uint8Array, type: CompressionType): Uint8Array {
173+
const result = new Uint8Array(1 + data.length);
174+
result[0] = type;
175+
result.set(data, 1);
176+
return result;
177+
}
178+
179+
#decompressData(data: Uint8Array): Uint8Array {
180+
const type = data[0];
181+
const compressedData = data.subarray(1);
182+
switch (type) {
183+
case CompressionType.None:
184+
return compressedData;
185+
case CompressionType.Zstd:
186+
return zstd.decompress(compressedData);
187+
default:
188+
throw new Error("Unknown compression type");
189+
}
190+
}
148191
}
149192

0 commit comments

Comments
 (0)