返回 JoyAI-Echo
imageEncode.worker.ts
根目录 / echo_longvideo / Director_Agent / webui / src / workers / imageEncode.worker.ts
1 /**
2 * Off-main-thread image encoder.
3 *
4 * Accepts a ``File``, validates it via magic bytes (ignoring the extension to
5 * defeat rename-based spoofs), and either passes through or *normalizes* the
6 * bytes so the resulting base64 data URL stays ≤ ``TARGET_MAX_BYTES``. The
7 * normalization path uses ``createImageBitmap`` + ``OffscreenCanvas`` so the
8 * full decode/resize/re-encode cycle never blocks the UI thread.
9 *
10 * Output contract:
11 * ``{ok: true, dataUrl, mime, bytes, origBytes, normalized}`` on success, or
12 * ``{ok: false, reason}`` for every recoverable failure — magic-bytes
13 * mismatch, unsupported MIME, decode error, or a post-normalization payload
14 * that *still* exceeds the budget (extreme aspect ratios).
15 */
16
17 /// <reference lib="webworker" />
18
19 // --- Types -------------------------------------------------------------------
20
21 export type EncodeInput = {
22 id: string;
23 file: File;
24 };
25
26 export type EncodeSuccess = {
27 id: string;
28 ok: true;
29 dataUrl: string;
30 mime: string;
31 bytes: number;
32 origBytes: number;
33 /** True iff the Worker re-encoded the image to hit the size budget. */
34 normalized: boolean;
35 };
36
37 export type EncodeFailure = {
38 id: string;
39 ok: false;
40 reason:
41 | "invalid_mime"
42 | "magic_mismatch"
43 | "too_large_after_normalize"
44 | "decode_failed"
45 | "io";
46 };
47
48 export type EncodeResponse = EncodeSuccess | EncodeFailure;
49
50 // --- Budgets -----------------------------------------------------------------
51
52 /** Upper bound for the final base64-decoded payload. Matches the server-side
53 * safeguard (8 MB) minus safety margin; anything this function yields should
54 * safely pass ``_MAX_IMAGE_BYTES`` on the server. */
55 export const TARGET_MAX_BYTES = 6 * 1024 * 1024;
56
57 /** Long-edge pixel cap when we resize a large image. 2048 keeps retina UIs
58 * crisp while bounding decode cost and matching most LLM vision tiers'
59 * internal downscale target. */
60 const NORMALIZE_MAX_EDGE = 2048;
61
62 /** JPEG/WebP quality during normalization. 0.85 is the sweet spot — visually
63 * lossless for content photography, ~30% smaller than libjpeg default. */
64 const WEBP_QUALITY = 0.85;
65
66 /** PNG / GIF kept as PNG after normalization so crisp UI screenshots stay
67 * lossless. JPEG / WebP re-encode as WebP for better compression. */
68 const NORMALIZE_LOSSY_MIMES = new Set(["image/jpeg", "image/webp"]);
69
70 const SUPPORTED_MIMES = new Set([
71 "image/png",
72 "image/jpeg",
73 "image/webp",
74 "image/gif",
75 ]);
76
77 // --- Magic bytes -------------------------------------------------------------
78
79 /** Sniff the first 12 bytes; returns the canonical MIME or ``null``.
80 *
81 * Covers PNG, JPEG, WebP, GIF — the same whitelist honoured by the server.
82 */
83 export function sniffImageMime(bytes: Uint8Array): string | null {
84 if (bytes.length >= 8) {
85 if (
86 bytes[0] === 0x89 &&
87 bytes[1] === 0x50 &&
88 bytes[2] === 0x4e &&
89 bytes[3] === 0x47 &&
90 bytes[4] === 0x0d &&
91 bytes[5] === 0x0a &&
92 bytes[6] === 0x1a &&
93 bytes[7] === 0x0a
94 ) {
95 return "image/png";
96 }
97 }
98 if (bytes.length >= 3) {
99 if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
100 return "image/jpeg";
101 }
102 }
103 if (bytes.length >= 6) {
104 const g1 =
105 bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 &&
106 bytes[3] === 0x38 && bytes[5] === 0x61;
107 if (g1 && (bytes[4] === 0x37 || bytes[4] === 0x39)) {
108 return "image/gif";
109 }
110 }
111 if (bytes.length >= 12) {
112 const riff =
113 bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46;
114 const webp =
115 bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
116 if (riff && webp) return "image/webp";
117 }
118 return null;
119 }
120
121 // --- Encoder -----------------------------------------------------------------
122
123 function bufferToBase64(buf: ArrayBuffer): string {
124 // ``btoa`` can't take large strings — chunk through 32 KB windows.
125 const bytes = new Uint8Array(buf);
126 let binary = "";
127 const CHUNK = 0x8000;
128 for (let i = 0; i < bytes.length; i += CHUNK) {
129 binary += String.fromCharCode.apply(
130 null,
131 bytes.subarray(i, i + CHUNK) as unknown as number[],
132 );
133 }
134 return self.btoa(binary);
135 }
136
137 function computeScaledDims(
138 srcW: number,
139 srcH: number,
140 maxEdge: number,
141 ): { w: number; h: number } {
142 const longest = Math.max(srcW, srcH);
143 if (longest <= maxEdge) return { w: srcW, h: srcH };
144 const scale = maxEdge / longest;
145 return {
146 w: Math.max(1, Math.round(srcW * scale)),
147 h: Math.max(1, Math.round(srcH * scale)),
148 };
149 }
150
151 async function normalize(
152 file: File,
153 sourceMime: string,
154 ): Promise<{ dataUrl: string; mime: string; bytes: number } | { error: EncodeFailure["reason"] }> {
155 // Re-encode paths: JPEG/WebP → WebP q=0.85; PNG/GIF → PNG (keep crisp).
156 const targetMime = NORMALIZE_LOSSY_MIMES.has(sourceMime)
157 ? "image/webp"
158 : "image/png";
159 let bitmap: ImageBitmap;
160 try {
161 bitmap = await createImageBitmap(file);
162 } catch {
163 return { error: "decode_failed" };
164 }
165 const { w, h } = computeScaledDims(bitmap.width, bitmap.height, NORMALIZE_MAX_EDGE);
166 try {
167 const canvas = new OffscreenCanvas(w, h);
168 const ctx = canvas.getContext("2d", { alpha: true });
169 if (!ctx) {
170 bitmap.close();
171 return { error: "decode_failed" };
172 }
173 ctx.imageSmoothingQuality = "high";
174 ctx.drawImage(bitmap, 0, 0, w, h);
175 bitmap.close();
176 const options: ImageEncodeOptions = { type: targetMime };
177 if (targetMime === "image/webp") options.quality = WEBP_QUALITY;
178 const blob = await canvas.convertToBlob(options);
179 if (blob.size > TARGET_MAX_BYTES) {
180 return { error: "too_large_after_normalize" };
181 }
182 const buf = await blob.arrayBuffer();
183 const dataUrl = `data:${targetMime};base64,${bufferToBase64(buf)}`;
184 return { dataUrl, mime: targetMime, bytes: blob.size };
185 } catch {
186 try {
187 bitmap.close();
188 } catch {
189 // bitmap already closed
190 }
191 return { error: "decode_failed" };
192 }
193 }
194
195 export async function encodeImageInWorker(
196 input: EncodeInput,
197 ): Promise<EncodeResponse> {
198 const { id, file } = input;
199 const origBytes = file.size;
200
201 let buffer: ArrayBuffer;
202 try {
203 buffer = await file.arrayBuffer();
204 } catch {
205 return { id, ok: false, reason: "io" };
206 }
207
208 const head = new Uint8Array(buffer.slice(0, 12));
209 const sniffed = sniffImageMime(head);
210 if (!sniffed) return { id, ok: false, reason: "magic_mismatch" };
211 if (!SUPPORTED_MIMES.has(sniffed)) {
212 return { id, ok: false, reason: "invalid_mime" };
213 }
214 // Defend against MIME spoofing: the declared ``file.type`` can lie.
215 if (file.type && SUPPORTED_MIMES.has(file.type) && file.type !== sniffed) {
216 // Trust the magic bytes; proceed with the sniffed MIME.
217 }
218
219 if (origBytes <= TARGET_MAX_BYTES) {
220 const dataUrl = `data:${sniffed};base64,${bufferToBase64(buffer)}`;
221 return {
222 id,
223 ok: true,
224 dataUrl,
225 mime: sniffed,
226 bytes: origBytes,
227 origBytes,
228 normalized: false,
229 };
230 }
231
232 const result = await normalize(file, sniffed);
233 if ("error" in result) {
234 return { id, ok: false, reason: result.error };
235 }
236 return {
237 id,
238 ok: true,
239 dataUrl: result.dataUrl,
240 mime: result.mime,
241 bytes: result.bytes,
242 origBytes,
243 normalized: true,
244 };
245 }
246
247 // --- Worker boot -------------------------------------------------------------
248 // Only attach the message listener when running *inside* a Worker so the same
249 // module can be imported by tests (and by the thin ``imageEncode.ts`` wrapper
250 // in the main thread, which also calls ``encodeImageInWorker`` as a
251 // fall-through path when the Worker isn't available).
252
253 declare const self: DedicatedWorkerGlobalScope;
254
255 if (
256 typeof self !== "undefined" &&
257 typeof (self as unknown as { importScripts?: unknown }).importScripts ===
258 "function"
259 ) {
260 self.addEventListener("message", async (event: MessageEvent<EncodeInput>) => {
261 const response = await encodeImageInWorker(event.data);
262 self.postMessage(response);
263 });
264 }
265
265 lines TYPESCRIPT