@rin-ts/sticker 0.0.0-stage → 0.2.0

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/src/native.ts ADDED
@@ -0,0 +1,546 @@
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import type { OutlineOptions, RgbaColor } from "./types.ts";
5
+
6
+ const REQUEST_MAGIC = "STK1";
7
+ const RESPONSE_MAGIC = "STR1";
8
+ const PROTOCOL_VERSION = 1;
9
+ const COMMAND_TRANSCODE = 1;
10
+ const TRIM_FLAG = 1 << 0;
11
+ const REQUEST_HEADER_BYTES = 18;
12
+ const RESPONSE_HEADER_BYTES = 26;
13
+ const FRAME_LENGTH_BYTES = 4;
14
+ const MAXIMUM_FRAME_BYTES = 17 * 1024 * 1024;
15
+ const MAXIMUM_SOURCE_BYTES = 16 * 1024 * 1024;
16
+ const MAXIMUM_STATIC_STICKER_BYTES = 100 * 1024;
17
+ const MAXIMUM_ANIMATED_STICKER_BYTES = 500 * 1024;
18
+ const MAXIMUM_ANIMATION_DURATION_MS = 10_000;
19
+ const DEFAULT_TIMEOUT_MS = 30_000;
20
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
21
+
22
+ type WorkerProcess = Bun.Subprocess<"pipe", "pipe", "ignore">;
23
+
24
+ interface WorkerSession {
25
+ readonly process: WorkerProcess;
26
+ readonly stdin: WorkerProcess["stdin"];
27
+ readonly reader: BufferedReader;
28
+ }
29
+
30
+ /**
31
+ * Bytes accepted as an input asset. The bytes are copied before a request is
32
+ * queued, so callers may safely reuse or mutate their original buffer.
33
+ */
34
+ export type NativeStickerSource = ArrayBuffer | ArrayBufferView;
35
+
36
+ export interface NativeStickerResult {
37
+ /** WhatsApp-compatible WebP bytes. */
38
+ readonly data: Uint8Array;
39
+ readonly width: number;
40
+ readonly height: number;
41
+ readonly frameCount: number;
42
+ readonly durationMs: number;
43
+ readonly isAnimated: boolean;
44
+ }
45
+
46
+ export interface NativeStickerTranscodeOptions {
47
+ /**
48
+ * Remove fully transparent outer rows and columns before sizing. Defaults
49
+ * to true.
50
+ */
51
+ readonly trim?: boolean;
52
+ /** Alpha at or below this byte value is treated as transparent while trimming. */
53
+ readonly alphaThreshold?: number;
54
+ /** Optional outline rendered behind the source image. */
55
+ readonly outline?: OutlineOptions;
56
+ /**
57
+ * Per-request deadline, including writes and reading the complete response.
58
+ * A timed-out worker is killed and a fresh worker is used for a later call.
59
+ */
60
+ readonly timeoutMs?: number;
61
+ }
62
+
63
+ export interface NativeStickerWorkerOptions {
64
+ /**
65
+ * Path to the `sticker_worker` executable. It defaults to the installed
66
+ * platform worker package, or the local CMake output for a source checkout.
67
+ */
68
+ readonly workerPath?: string;
69
+ /**
70
+ * Optional arguments after `workerPath`. This is mainly useful for wrapping
71
+ * or testing the worker process.
72
+ */
73
+ readonly workerArgs?: readonly string[];
74
+ /** Default per-request deadline in milliseconds. Defaults to 30 seconds. */
75
+ readonly timeoutMs?: number;
76
+ }
77
+
78
+ /**
79
+ * A response produced by the worker for a valid request that it could not
80
+ * process, for example because the source format is unsupported.
81
+ */
82
+ export class NativeStickerWorkerError extends Error {
83
+ readonly status: number;
84
+ readonly code: number;
85
+
86
+ constructor(message: string, status: number, code: number) {
87
+ super(message);
88
+ this.name = "NativeStickerWorkerError";
89
+ this.status = status;
90
+ this.code = code;
91
+ }
92
+ }
93
+
94
+ /** The native worker did not finish one request before its configured deadline. */
95
+ export class NativeStickerWorkerTimeoutError extends Error {
96
+ readonly timeoutMs: number;
97
+
98
+ constructor(timeoutMs: number) {
99
+ super(`Sticker worker exceeded its ${timeoutMs} ms deadline.`);
100
+ this.name = "NativeStickerWorkerTimeoutError";
101
+ this.timeoutMs = timeoutMs;
102
+ }
103
+ }
104
+
105
+ /** The worker exited, returned malformed protocol data, or could not be started. */
106
+ export class NativeStickerWorkerTransportError extends Error {
107
+ constructor(message: string) {
108
+ super(message);
109
+ this.name = "NativeStickerWorkerTransportError";
110
+ }
111
+ }
112
+
113
+ /** A caller attempted to enqueue work after the worker was closed. */
114
+ export class NativeStickerWorkerClosedError extends Error {
115
+ constructor() {
116
+ super("Sticker worker is closed.");
117
+ this.name = "NativeStickerWorkerClosedError";
118
+ }
119
+ }
120
+
121
+ class WorkerProtocolError extends NativeStickerWorkerTransportError {
122
+ constructor(message: string) {
123
+ super(message);
124
+ this.name = "WorkerProtocolError";
125
+ }
126
+ }
127
+
128
+ class BufferedReader {
129
+ readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
130
+ #current: Uint8Array | undefined;
131
+ #offset = 0;
132
+
133
+ constructor(stream: ReadableStream<Uint8Array>) {
134
+ this.#reader = stream.getReader();
135
+ }
136
+
137
+ async readExactly(byteLength: number): Promise<Uint8Array> {
138
+ const result = new Uint8Array(byteLength);
139
+ let written = 0;
140
+
141
+ while (written < byteLength) {
142
+ let current = this.#current;
143
+ if (current === undefined || this.#offset === current.byteLength) {
144
+ const next = await this.#reader.read();
145
+ if (next.done) {
146
+ throw new NativeStickerWorkerTransportError(
147
+ "Sticker worker closed stdout before its response was complete.",
148
+ );
149
+ }
150
+ if (next.value.byteLength === 0) {
151
+ continue;
152
+ }
153
+ current = next.value;
154
+ this.#current = current;
155
+ this.#offset = 0;
156
+ }
157
+
158
+ const available = current.byteLength - this.#offset;
159
+ const needed = byteLength - written;
160
+ const copied = Math.min(available, needed);
161
+ result.set(current.subarray(this.#offset, this.#offset + copied), written);
162
+ this.#offset += copied;
163
+ written += copied;
164
+ }
165
+
166
+ return result;
167
+ }
168
+
169
+ cancel(): void {
170
+ void this.#reader.cancel().catch(() => undefined);
171
+ }
172
+ }
173
+
174
+ function platformWorkerPackage(): string | undefined {
175
+ if (process.platform !== "linux") {
176
+ return undefined;
177
+ }
178
+
179
+ if (process.arch === "x64") {
180
+ return "@rin-ts/sticker-linux-x64-gnu";
181
+ }
182
+ if (process.arch === "arm64") {
183
+ return "@rin-ts/sticker-linux-arm64-gnu";
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ function installedWorkerPath(executable: string): string | undefined {
189
+ const packageName = platformWorkerPackage();
190
+ if (packageName === undefined) {
191
+ return undefined;
192
+ }
193
+
194
+ try {
195
+ const manifestUrl = import.meta.resolve(`${packageName}/package.json`);
196
+ return resolve(fileURLToPath(manifestUrl), "..", "bin", executable);
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function defaultWorkerPath(): string {
203
+ const executable = process.platform === "win32" ? "sticker_worker.exe" : "sticker_worker";
204
+ return installedWorkerPath(executable)
205
+ ?? resolve(import.meta.dir, "../build/native/bin", executable);
206
+ }
207
+
208
+ function requireByte(value: number, name: string): number {
209
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
210
+ throw new RangeError(`${name} must be an integer from 0 through 255.`);
211
+ }
212
+ return value;
213
+ }
214
+
215
+ function requireTimeout(value: number, name: string): number {
216
+ if (!Number.isSafeInteger(value) || value <= 0) {
217
+ throw new RangeError(`${name} must be a positive integer.`);
218
+ }
219
+ return value;
220
+ }
221
+
222
+ function readUint32(bytes: Uint8Array, offset: number): number {
223
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
224
+ }
225
+
226
+ function sourceBytes(source: NativeStickerSource): Uint8Array {
227
+ if (source instanceof ArrayBuffer) {
228
+ return new Uint8Array(source);
229
+ }
230
+ if (ArrayBuffer.isView(source)) {
231
+ return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
232
+ }
233
+ throw new TypeError("source must be an ArrayBuffer or an ArrayBuffer view.");
234
+ }
235
+
236
+ function encodeColor(color: RgbaColor): readonly [number, number, number, number] {
237
+ return [
238
+ requireByte(color.red, "outline.color.red"),
239
+ requireByte(color.green, "outline.color.green"),
240
+ requireByte(color.blue, "outline.color.blue"),
241
+ requireByte(color.alpha ?? 255, "outline.color.alpha"),
242
+ ];
243
+ }
244
+
245
+ function encodeRequest(
246
+ source: NativeStickerSource,
247
+ options: NativeStickerTranscodeOptions,
248
+ ): Uint8Array {
249
+ if (options.trim !== undefined && typeof options.trim !== "boolean") {
250
+ throw new TypeError("trim must be a boolean.");
251
+ }
252
+
253
+ const input = sourceBytes(source);
254
+ if (input.byteLength > MAXIMUM_SOURCE_BYTES) {
255
+ throw new RangeError(
256
+ `Source exceeds the ${MAXIMUM_SOURCE_BYTES}-byte native worker input limit.`,
257
+ );
258
+ }
259
+
260
+ const alphaThreshold = requireByte(options.alphaThreshold ?? 0, "alphaThreshold");
261
+ const outlineWidth = options.outline?.width ?? 0;
262
+ if (!Number.isInteger(outlineWidth) || outlineWidth < 0 || outlineWidth > 64) {
263
+ throw new RangeError("outline.width must be an integer from 0 through 64.");
264
+ }
265
+ const color = encodeColor(
266
+ options.outline?.color ?? { red: 255, green: 255, blue: 255, alpha: 255 },
267
+ );
268
+
269
+ const frame = new Uint8Array(FRAME_LENGTH_BYTES + REQUEST_HEADER_BYTES + input.byteLength);
270
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
271
+ view.setUint32(0, REQUEST_HEADER_BYTES + input.byteLength, true);
272
+
273
+ const header = FRAME_LENGTH_BYTES;
274
+ frame[header] = REQUEST_MAGIC.charCodeAt(0);
275
+ frame[header + 1] = REQUEST_MAGIC.charCodeAt(1);
276
+ frame[header + 2] = REQUEST_MAGIC.charCodeAt(2);
277
+ frame[header + 3] = REQUEST_MAGIC.charCodeAt(3);
278
+ frame[header + 4] = PROTOCOL_VERSION;
279
+ frame[header + 5] = COMMAND_TRANSCODE;
280
+ frame[header + 6] = (options.trim ?? true) ? TRIM_FLAG : 0;
281
+ frame[header + 7] = 0;
282
+ frame[header + 8] = alphaThreshold;
283
+ frame[header + 9] = outlineWidth;
284
+ frame[header + 10] = color[0];
285
+ frame[header + 11] = color[1];
286
+ frame[header + 12] = color[2];
287
+ frame[header + 13] = color[3];
288
+ view.setUint32(header + 14, input.byteLength, true);
289
+ frame.set(input, header + REQUEST_HEADER_BYTES);
290
+ return frame;
291
+ }
292
+
293
+ function decodeResponse(frame: Uint8Array): NativeStickerResult {
294
+ if (frame.byteLength < RESPONSE_HEADER_BYTES) {
295
+ throw new WorkerProtocolError("Sticker worker response is shorter than its header.");
296
+ }
297
+
298
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
299
+ for (let index = 0; index < RESPONSE_MAGIC.length; index += 1) {
300
+ if (view.getUint8(index) !== RESPONSE_MAGIC.charCodeAt(index)) {
301
+ throw new WorkerProtocolError("Sticker worker response has an invalid magic value.");
302
+ }
303
+ }
304
+ if (view.getUint8(4) !== PROTOCOL_VERSION) {
305
+ throw new WorkerProtocolError("Sticker worker response uses an unsupported protocol version.");
306
+ }
307
+
308
+ const status = view.getUint8(5);
309
+ if (status !== 0 && status !== 1 && status !== 2) {
310
+ throw new WorkerProtocolError("Sticker worker response has an unknown status.");
311
+ }
312
+ const code = view.getUint8(6);
313
+ if ((status === 0 && code !== 0) || (status !== 0 && code === 0)) {
314
+ throw new WorkerProtocolError("Sticker worker response has an invalid error code.");
315
+ }
316
+ const animatedFlag = view.getUint8(7);
317
+ if (animatedFlag !== 0 && animatedFlag !== 1) {
318
+ throw new WorkerProtocolError("Sticker worker response has an invalid animation flag.");
319
+ }
320
+
321
+ const width = view.getUint16(8, true);
322
+ const height = view.getUint16(10, true);
323
+ const frameCount = view.getUint32(12, true);
324
+ const durationMs = view.getUint32(16, true);
325
+ const outputBytes = view.getUint32(20, true);
326
+ const messageBytes = view.getUint16(24, true);
327
+ const expectedLength = RESPONSE_HEADER_BYTES + outputBytes + messageBytes;
328
+ if (expectedLength !== frame.byteLength) {
329
+ throw new WorkerProtocolError("Sticker worker response length does not match its header.");
330
+ }
331
+
332
+ const outputStart = RESPONSE_HEADER_BYTES;
333
+ const messageStart = outputStart + outputBytes;
334
+ let message = "";
335
+ try {
336
+ message = textDecoder.decode(frame.subarray(messageStart));
337
+ } catch {
338
+ throw new WorkerProtocolError("Sticker worker response has a malformed UTF-8 message.");
339
+ }
340
+
341
+ if (status !== 0) {
342
+ if (outputBytes !== 0) {
343
+ throw new WorkerProtocolError("Sticker worker returned output bytes with an error status.");
344
+ }
345
+ throw new NativeStickerWorkerError(
346
+ message || "Sticker worker could not process the source.",
347
+ status,
348
+ code,
349
+ );
350
+ }
351
+
352
+ if (outputBytes === 0 || messageBytes !== 0) {
353
+ throw new WorkerProtocolError("Sticker worker returned an invalid successful response.");
354
+ }
355
+ if (width !== 512 || height !== 512 || frameCount === 0) {
356
+ throw new WorkerProtocolError("Sticker worker returned invalid sticker metadata.");
357
+ }
358
+ const isAnimated = animatedFlag === 1;
359
+ if (!isAnimated && frameCount !== 1) {
360
+ throw new WorkerProtocolError("Sticker worker marked a multi-frame result as static.");
361
+ }
362
+ if (isAnimated && frameCount < 2) {
363
+ throw new WorkerProtocolError("Sticker worker marked a single-frame result as animated.");
364
+ }
365
+ if (isAnimated && durationMs > MAXIMUM_ANIMATION_DURATION_MS) {
366
+ throw new WorkerProtocolError("Sticker worker returned an overlong animation.");
367
+ }
368
+ const outputLimit = isAnimated
369
+ ? MAXIMUM_ANIMATED_STICKER_BYTES
370
+ : MAXIMUM_STATIC_STICKER_BYTES;
371
+ if (outputBytes > outputLimit) {
372
+ throw new WorkerProtocolError("Sticker worker returned an oversized WebP.");
373
+ }
374
+
375
+ return {
376
+ data: frame.slice(outputStart, messageStart),
377
+ width,
378
+ height,
379
+ frameCount,
380
+ durationMs,
381
+ isAnimated,
382
+ };
383
+ }
384
+
385
+ function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
386
+ let timer: ReturnType<typeof setTimeout> | undefined;
387
+ const deadline = new Promise<never>((_resolve, reject) => {
388
+ timer = setTimeout(() => reject(new NativeStickerWorkerTimeoutError(timeoutMs)), timeoutMs);
389
+ });
390
+ return Promise.race([operation, deadline]).finally(() => {
391
+ if (timer !== undefined) {
392
+ clearTimeout(timer);
393
+ }
394
+ });
395
+ }
396
+
397
+ /**
398
+ * A persistent, serial client for the native C++ sticker worker.
399
+ *
400
+ * Calls are deliberately serialized: one worker owns one stdin/stdout stream,
401
+ * which keeps protocol framing deterministic and bounds the worker's peak
402
+ * image memory. If a process dies, sends malformed data, or times out, it is
403
+ * discarded; the next queued call starts a clean worker.
404
+ */
405
+ export class NativeStickerWorker {
406
+ readonly #workerPath: string;
407
+ readonly #workerArgs: readonly string[];
408
+ readonly #defaultTimeoutMs: number;
409
+ #session: WorkerSession | undefined;
410
+ #tail: Promise<void> = Promise.resolve();
411
+ #closed = false;
412
+ #closePromise: Promise<void> | undefined;
413
+
414
+ constructor(options: NativeStickerWorkerOptions = {}) {
415
+ const workerPath = options.workerPath ?? defaultWorkerPath();
416
+ if (workerPath.length === 0) {
417
+ throw new RangeError("workerPath must not be empty.");
418
+ }
419
+ if (options.workerArgs?.some((argument) => typeof argument !== "string")) {
420
+ throw new TypeError("workerArgs must contain only strings.");
421
+ }
422
+
423
+ this.#workerPath = workerPath;
424
+ this.#workerArgs = [...(options.workerArgs ?? [])];
425
+ this.#defaultTimeoutMs = requireTimeout(
426
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
427
+ "timeoutMs",
428
+ );
429
+ }
430
+
431
+ /**
432
+ * Enqueues one input asset for conversion. Its source bytes are copied when
433
+ * this method is called, before waiting behind any earlier request.
434
+ */
435
+ transcode(
436
+ source: NativeStickerSource,
437
+ options: NativeStickerTranscodeOptions = {},
438
+ ): Promise<NativeStickerResult> {
439
+ if (this.#closed) {
440
+ return Promise.reject(new NativeStickerWorkerClosedError());
441
+ }
442
+
443
+ const request = encodeRequest(source, options);
444
+ const timeoutMs = requireTimeout(options.timeoutMs ?? this.#defaultTimeoutMs, "timeoutMs");
445
+ const work = this.#tail.then(() => this.#transcode(request, timeoutMs));
446
+ this.#tail = work.then(
447
+ () => undefined,
448
+ () => undefined,
449
+ );
450
+ return work;
451
+ }
452
+
453
+ /**
454
+ * Stops accepting new work, drains requests already accepted, then terminates
455
+ * the native child process.
456
+ */
457
+ close(): Promise<void> {
458
+ if (this.#closePromise !== undefined) {
459
+ return this.#closePromise;
460
+ }
461
+
462
+ this.#closed = true;
463
+ this.#closePromise = this.#tail.then(() => {
464
+ const session = this.#session;
465
+ if (session !== undefined) {
466
+ this.#discardSession(session);
467
+ }
468
+ });
469
+ return this.#closePromise;
470
+ }
471
+
472
+ #startSession(): WorkerSession {
473
+ let process: WorkerProcess;
474
+ try {
475
+ process = Bun.spawn([this.#workerPath, ...this.#workerArgs], {
476
+ stdin: "pipe",
477
+ stdout: "pipe",
478
+ stderr: "ignore",
479
+ });
480
+ } catch (error) {
481
+ const reason = error instanceof Error ? `: ${error.message}` : "";
482
+ throw new NativeStickerWorkerTransportError(
483
+ `Unable to start sticker worker "${this.#workerPath}"${reason}`,
484
+ );
485
+ }
486
+
487
+ const session: WorkerSession = {
488
+ process,
489
+ stdin: process.stdin,
490
+ reader: new BufferedReader(process.stdout),
491
+ };
492
+ this.#session = session;
493
+ return session;
494
+ }
495
+
496
+ #discardSession(session: WorkerSession): void {
497
+ if (this.#session === session) {
498
+ this.#session = undefined;
499
+ }
500
+ session.reader.cancel();
501
+ try {
502
+ if (!session.process.killed && session.process.exitCode === null) {
503
+ session.process.kill();
504
+ }
505
+ } catch {
506
+ // The process may already have exited between the checks and kill().
507
+ }
508
+ void session.process.exited.catch(() => undefined);
509
+ }
510
+
511
+ async #transcode(request: Uint8Array, timeoutMs: number): Promise<NativeStickerResult> {
512
+ const session = this.#session ?? this.#startSession();
513
+ try {
514
+ return await withTimeout(this.#exchange(session, request), timeoutMs);
515
+ } catch (error) {
516
+ if (!(error instanceof NativeStickerWorkerError)) {
517
+ this.#discardSession(session);
518
+ }
519
+ throw error;
520
+ }
521
+ }
522
+
523
+ async #exchange(session: WorkerSession, request: Uint8Array): Promise<NativeStickerResult> {
524
+ if (session.process.exitCode !== null) {
525
+ throw new NativeStickerWorkerTransportError("Sticker worker exited before it accepted a request.");
526
+ }
527
+
528
+ await session.stdin.write(request);
529
+ await session.stdin.flush();
530
+
531
+ const lengthBytes = await session.reader.readExactly(FRAME_LENGTH_BYTES);
532
+ const responseLength = readUint32(lengthBytes, 0);
533
+ if (responseLength < RESPONSE_HEADER_BYTES || responseLength > MAXIMUM_FRAME_BYTES) {
534
+ throw new WorkerProtocolError("Sticker worker response frame exceeds protocol limits.");
535
+ }
536
+ const response = await session.reader.readExactly(responseLength);
537
+ return decodeResponse(response);
538
+ }
539
+ }
540
+
541
+ /** Creates a persistent native C++ worker client. */
542
+ export function createNativeStickerWorker(
543
+ options?: NativeStickerWorkerOptions,
544
+ ): NativeStickerWorker {
545
+ return new NativeStickerWorker(options);
546
+ }
package/src/types.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface RgbaColor {
2
+ readonly red: number;
3
+ readonly green: number;
4
+ readonly blue: number;
5
+ readonly alpha?: number;
6
+ }
7
+
8
+ export interface OutlineOptions {
9
+ readonly color: RgbaColor;
10
+ /** Outline radius in pixels. */
11
+ readonly width: number;
12
+ }