@rin-ts/sticker 0.2.0 → 0.2.1

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 CHANGED
@@ -1,84 +1,47 @@
1
1
  # @rin-ts/sticker
2
2
 
3
- `@rin-ts/sticker` converts media into WhatsApp-compatible WebP stickers for
4
- Rin. It keeps codec and pixel work in a persistent native C++ worker, while
5
- the Bun/TypeScript API manages requests and returns the finished WebP bytes.
3
+ [![npm](https://img.shields.io/npm/v/%40rin-ts%2Fsticker?logo=npm)](https://www.npmjs.com/package/@rin-ts/sticker)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/rin-ts/sticker/ci.yml?branch=main&label=CI)](https://github.com/rin-ts/sticker/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/npm/l/%40rin-ts%2Fsticker)](LICENSE)
6
6
 
7
- The design is intended for fast, predictable server-side processing: requests
8
- are serialized per worker, source and output sizes are bounded, and a worker
9
- that times out or returns an invalid response is replaced before later work is
10
- accepted.
7
+ Native WhatsApp WebP sticker conversion for Bun and TypeScript. Media decoding,
8
+ pixel processing, and WebP encoding run in a persistent C++ worker; the
9
+ TypeScript API sends bytes and returns validated sticker bytes.
11
10
 
12
- ## Supported media
11
+ ## Highlights
13
12
 
14
- - PNG, JPEG, static WebP, animated WebP, and videos decoded by FFmpeg
15
- (including MP4, MOV, WebM, MKV, and AVI)
16
- - Transparency, alpha trimming, proportional 512 × 512 sizing, and optional
17
- alpha-aware outlines
18
- - Static and animated WhatsApp-compatible WebP output
19
-
20
- GIF, APNG, and Lottie/Telegram `.tgs` remain rejected. Videos are decoded
21
- directly from the supplied bytes; audio streams are ignored.
13
+ - PNG, JPEG, static WebP, animated WebP, and FFmpeg-decoded video input
14
+ - 512 × 512 static or animated WebP output validated against conservative
15
+ WhatsApp sticker limits
16
+ - Alpha trimming, proportional sizing, and alpha-aware outlines
17
+ - Fast high-quality lossy output by default, with an opt-in lossless static attempt
18
+ - Persistent workers, per-request deadlines, bounded input, and automatic
19
+ worker replacement after a transport failure or timeout
22
20
 
23
21
  ## Install
24
22
 
25
- Published packages include a prebuilt worker for Linux x64 and Linux ARM64.
26
- They are available from the public npm registry, so no registry configuration
27
- or authentication is needed.
28
-
29
23
  ```bash
30
24
  bun add @rin-ts/sticker
31
25
  ```
32
26
 
33
- The optional platform worker is selected automatically. Set `workerPath` only
34
- when you supply a custom worker binary.
35
-
36
- ## Publishing
37
-
38
- Maintain a matching `v<version>` Git tag and package versions, then a
39
- maintainer runs the **Publish packages** workflow from GitHub Actions with that
40
- tag and the intended npm distribution tag. The workflow publishes the Linux
41
- packages before the main package and safely skips versions already on npm.
42
- GitHub requires repository write access to start this manual workflow.
43
-
44
- Before the first release, configure an npm trusted publisher for each package:
45
- `@rin-ts/sticker`, `@rin-ts/sticker-linux-x64-gnu`, and
46
- `@rin-ts/sticker-linux-arm64-gnu`. Use GitHub organization `rin-ts`,
47
- repository `sticker`, workflow filename `release.yml`, and allow direct
48
- `npm publish`. This uses GitHub OIDC credentials rather than a stored npm
49
- token.
50
-
51
- ## Build from source
52
-
53
- The native build requires Bun, CMake 3.20+, a C++20 compiler, `pkg-config`,
54
- and development packages for libpng, libjpeg, libwebp, libwebpmux,
55
- libwebpdemux, libavformat, libavcodec, libavutil, and libswscale. On Void,
56
- install these FFmpeg headers with `sudo xbps-install -S ffmpeg6-devel`.
57
-
58
- ```bash
59
- bun install
60
- bun run build:native
61
- ```
27
+ Prebuilt workers are selected automatically on Linux x64 and Linux ARM64.
28
+ Pass `workerPath` only when using a custom native worker executable.
29
+ Keep a custom worker on the same package version as its TypeScript wrapper. A
30
+ newer wrapper can send ordinary requests to an older worker, but that worker
31
+ keeps its older encoding default; `preferLossless: true` is rejected by an
32
+ older worker.
62
33
 
63
- This produces `build/native/bin/sticker_worker`.
34
+ ## Quick start
64
35
 
65
- ## Usage
66
-
67
- Create one worker when the application starts, then reuse it for every sticker
68
- request. Supply already-bounded media bytes; the worker accepts at most 16 MiB
69
- per source asset.
36
+ Create a worker once during application startup and reuse it for requests.
70
37
 
71
38
  ```ts
72
39
  import { createNativeStickerWorker } from "@rin-ts/sticker";
73
40
 
74
- const stickerWorker = createNativeStickerWorker({
75
- // workerPath: "/opt/rin/sticker_worker",
76
- timeoutMs: 15_000,
77
- });
78
-
41
+ const worker = createNativeStickerWorker({ timeoutMs: 15_000 });
79
42
  const source = await Bun.file("./photo.png").arrayBuffer();
80
43
 
81
- const sticker = await stickerWorker.transcode(source, {
44
+ const sticker = await worker.transcode(source, {
82
45
  trim: true,
83
46
  alphaThreshold: 4,
84
47
  outline: {
@@ -87,34 +50,139 @@ const sticker = await stickerWorker.transcode(source, {
87
50
  },
88
51
  });
89
52
 
90
- // For a Zapo/Rin handler:
91
53
  await context.reply({ type: "sticker", media: sticker.data });
54
+ await worker.close();
55
+ ```
56
+
57
+ `transcode()` resolves to:
58
+
59
+ | Field | Description |
60
+ | --- | --- |
61
+ | `data` | Validated WebP sticker bytes |
62
+ | `width`, `height` | Always `512` on success |
63
+ | `frameCount`, `durationMs` | Animation metadata; static stickers have one frame and zero duration |
64
+ | `isAnimated` | Whether the output contains multiple frames |
65
+
66
+ ## Input support
67
+
68
+ | Input | Result |
69
+ | --- | --- |
70
+ | PNG, JPEG, static WebP | Static WebP sticker |
71
+ | Animated WebP | Animated WebP sticker |
72
+ | FFmpeg-supported video such as MP4, MOV, WebM, MKV, and AVI | Animated WebP sticker; audio is ignored |
73
+ | GIF, APNG, Lottie/Telegram `.tgs` | Rejected |
92
74
 
93
- // During application shutdown:
94
- await stickerWorker.close();
75
+ The worker receives bytes only; it is not a file-path CLI or an HTTP service.
76
+
77
+ ## API
78
+
79
+ ```ts
80
+ const worker = createNativeStickerWorker({
81
+ workerPath: "/opt/rin/sticker_worker", // optional
82
+ workerArgs: [], // optional
83
+ timeoutMs: 30_000, // optional
84
+ });
95
85
  ```
96
86
 
97
- `transcode()` returns `data` (the WebP bytes), `width`, `height`,
98
- `frameCount`, `durationMs`, and `isAnimated`.
87
+ `worker.transcode(source, options)` accepts an `ArrayBuffer` or any typed-array
88
+ view. `options` are:
89
+
90
+ | Option | Default | Description |
91
+ | --- | --- | --- |
92
+ | `trim` | `true` | Remove fully transparent outer rows and columns |
93
+ | `alphaThreshold` | `0` | Alpha value treated as transparent while trimming |
94
+ | `outline.width` | `0` | Outline radius, from 0 through 64 pixels |
95
+ | `outline.color` | opaque white | `{ red, green, blue, alpha? }`, each channel 0 through 255 |
96
+ | `preferLossless` | `false` | Attempt lossless static WebP before lossy fallback; it still falls back when output exceeds 100 KiB |
97
+ | `timeoutMs` | worker default (30,000 ms) | Deadline for writing, conversion, and reading the response |
98
+
99
+ Calls to one worker are intentionally serialized. Use a finite pool of workers
100
+ when parallel conversion is required. `close()` stops new work, drains already
101
+ accepted work, and terminates the child process.
102
+
103
+ ## Limits and production behavior
99
104
 
100
- ## Output and operational limits
105
+ - Source assets are limited to 16 MiB.
106
+ - Output is always 512 × 512 WebP.
107
+ - Static output is limited to 100 KiB.
108
+ - Animated output is limited to 500 KiB, 10 seconds, and requires multiple
109
+ frames with delays of at least 8 ms.
101
110
 
102
- Successful output is validated as 512 × 512 WebP. Static stickers are capped
103
- at 100 KiB; animated stickers are capped at 500 KiB, must contain more than
104
- one frame, use frame delays of at least 8 ms, and last no more than 10 seconds.
105
- These are conservative WhatsApp sticker-pack compatibility targets.
111
+ The worker is discarded after a timeout, malformed protocol response, or
112
+ transport failure; the next queued call starts a clean process. Still enforce
113
+ download limits before calling this package, set an appropriate worker-pool
114
+ size, and apply OS-level CPU and memory limits in production.
106
115
 
107
- The worker is an isolated stdin/stdout subprocess, not a file-path CLI or an
108
- HTTP server. It accepts bytes and rendering options only. For production,
109
- continue to enforce download limits in the application, use finite worker
110
- pools, and run workers with appropriate OS-level CPU and memory limits.
116
+ ## Benchmarking
111
117
 
112
- For development:
118
+ Run the repeatable end-to-end suite:
113
119
 
114
120
  ```bash
115
- bun run test:native
121
+ bun run benchmark
116
122
  ```
117
123
 
124
+ It builds the native worker, generates deterministic full-size media fixtures
125
+ locally, then reports cold latency (worker spawn plus first conversion) and
126
+ warm latency (a reused worker). File I/O is excluded from warm measurements.
127
+ The default fixtures cover a 1280 × 854 image as PNG, JPEG, and static WebP; a
128
+ 1024 × 1024 transparent graphic; a three-frame animated WebP; and a
129
+ three-frame 640 × 360 MP4. They are real encoded media files, not 1 × 1 test
130
+ pixels, and no fixture is downloaded.
131
+
132
+ Use your own media to measure the inputs your application handles:
133
+
134
+ ```bash
135
+ bun run build:native
136
+ # Compare the fast default and lossless preference using the same source:
137
+ bun run benchmarks/benchmark.ts --iterations 10 ./artwork.webp
138
+ bun run benchmarks/benchmark.ts --iterations 10 --prefer-lossless ./artwork.webp
139
+ ```
140
+
141
+ The report includes source and output size, cold latency, warm median and p95,
142
+ and warm throughput. Performance varies with codec library versions, CPU,
143
+ source dimensions, frame count, transparency, and how often an output must be
144
+ re-encoded to meet its byte limit; compare results on the deployment hardware.
145
+ Static output uses the high-quality lossy path by default for predictable
146
+ latency. Set `preferLossless: true` when lower latency is not important and a
147
+ lossless attempt is worthwhile; output still falls back to lossy above 100 KiB.
148
+
149
+ ## Development
150
+
151
+ ### Build from source
152
+
153
+ The native build requires Bun, CMake 3.20+, a C++20 compiler, `pkg-config`,
154
+ and development packages for libpng, libjpeg, libwebp, libwebpmux,
155
+ libwebpdemux, libavformat, libavcodec, libavutil, and libswscale. On Void,
156
+ install FFmpeg headers with:
157
+
158
+ ```bash
159
+ sudo xbps-install -S ffmpeg6-devel
160
+ ```
161
+
162
+ Then build:
163
+
164
+ ```bash
165
+ bun install
166
+ bun run build:native
167
+ ```
168
+
169
+ The worker is written to `build/native/bin/sticker_worker`.
170
+
171
+ ### Test
172
+
173
+ ```bash
174
+ bun run test
175
+ ```
176
+
177
+ ## Publishing
178
+
179
+ Maintain matching package versions and a `v<version>` tag, then run the
180
+ repository’s **Publish packages** GitHub Actions workflow with the intended npm
181
+ distribution tag. It publishes the platform packages before the main package
182
+ and skips package versions already present on npm. Configure npm trusted
183
+ publishers for the main and Linux platform packages after the initial release;
184
+ the workflow file is [`release.yml`](.github/workflows/release.yml).
185
+
118
186
  ## License
119
187
 
120
188
  GPL-3.0-only. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rin-ts/sticker",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Native C++ WhatsApp WebP sticker worker for Rin.",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -23,6 +23,8 @@
23
23
  },
24
24
  "scripts": {
25
25
  "build:native": "bun run scripts/build-native.ts",
26
+ "benchmark:fixtures": "bun run build:native && bun run benchmarks/generate-fixtures.ts",
27
+ "benchmark": "bun run benchmark:fixtures && bun run benchmarks/benchmark.ts",
26
28
  "test": "bun run typecheck && bun run test:native",
27
29
  "test:native": "bun run build:native && ctest --test-dir build/native --output-on-failure && bun test ./tests/native_bridge.test.ts",
28
30
  "typecheck": "bun check"
@@ -34,7 +36,7 @@
34
36
  "typescript": "^7"
35
37
  },
36
38
  "optionalDependencies": {
37
- "@rin-ts/sticker-linux-arm64-gnu": "0.2.0",
38
- "@rin-ts/sticker-linux-x64-gnu": "0.2.0"
39
+ "@rin-ts/sticker-linux-arm64-gnu": "0.2.1",
40
+ "@rin-ts/sticker-linux-x64-gnu": "0.2.1"
39
41
  }
40
42
  }
@@ -1379,11 +1379,14 @@ private:
1379
1379
  }
1380
1380
  } else {
1381
1381
  config.lossless = 0;
1382
- config.method = 6;
1382
+ // The default static path favors bounded latency. Transparent artwork can
1383
+ // spend seconds optimizing a lossless-quality alpha plane at method 6.
1384
+ // Method 4 keeps alpha edges exact without exhaustive analysis.
1385
+ config.method = 4;
1383
1386
  config.alpha_quality = 100;
1384
1387
  config.thread_level = 1;
1385
1388
  config.exact = 0;
1386
- config.use_sharp_yuv = 1;
1389
+ config.use_sharp_yuv = 0;
1387
1390
  }
1388
1391
  if (WebPValidateConfig(&config) == 0) {
1389
1392
  fail(ErrorCode::kInternal, "invalid WebP encoder configuration");
@@ -1452,10 +1455,14 @@ encode_static(const Image &image, const int quality, const bool lossless) {
1452
1455
  };
1453
1456
  }
1454
1457
 
1455
- [[nodiscard]] StickerResult encode_static_limited(const Image &image) {
1456
- std::vector<Byte> encoded = encode_static(image, 100, true);
1457
- if (encoded.size() <= kMaximumStaticStickerBytes) {
1458
- return validate_output(std::move(encoded), false);
1458
+ [[nodiscard]] StickerResult encode_static_limited(const Image &image,
1459
+ const bool prefer_lossless) {
1460
+ std::vector<Byte> encoded;
1461
+ if (prefer_lossless) {
1462
+ encoded = encode_static(image, 100, true);
1463
+ if (encoded.size() <= kMaximumStaticStickerBytes) {
1464
+ return validate_output(std::move(encoded), false);
1465
+ }
1459
1466
  }
1460
1467
 
1461
1468
  constexpr std::array<int, 9U> kQualityCandidates = {90, 80, 70, 60, 50,
@@ -1804,7 +1811,7 @@ encode_video_animation_limited(const std::span<const Byte> source,
1804
1811
  Image rendered =
1805
1812
  render_frame(decoded.rgba, decoded.width, decoded.height, geometry);
1806
1813
  apply_outline(rendered, options);
1807
- return encode_static_limited(rendered);
1814
+ return encode_static_limited(rendered, options.prefer_lossless);
1808
1815
  }
1809
1816
 
1810
1817
  [[nodiscard]] StickerResult
@@ -1812,8 +1819,10 @@ transcode_animated_webp(const std::span<const Byte> source,
1812
1819
  const WebpInfo &info, const TranscodeOptions &options) {
1813
1820
  const AnimationPlan plan = plan_animation(source, info, options);
1814
1821
  if (plan.selections.size() == 1U) {
1815
- return encode_static_limited(render_animation_frame(
1816
- source, info, plan, plan.selections.front().source_index, options));
1822
+ return encode_static_limited(
1823
+ render_animation_frame(source, info, plan,
1824
+ plan.selections.front().source_index, options),
1825
+ options.prefer_lossless);
1817
1826
  }
1818
1827
  return encode_animation_limited(source, info, plan, options);
1819
1828
  }
@@ -1824,8 +1833,10 @@ transcode_animated_video(const std::span<const Byte> source,
1824
1833
  const TranscodeOptions &options) {
1825
1834
  const AnimationPlan plan = plan_video_animation(source, info, options);
1826
1835
  if (plan.selections.size() == 1U) {
1827
- return encode_static_limited(render_video_animation_frame(
1828
- source, info, plan, plan.selections.front().source_index, options));
1836
+ return encode_static_limited(
1837
+ render_video_animation_frame(
1838
+ source, info, plan, plan.selections.front().source_index, options),
1839
+ options.prefer_lossless);
1829
1840
  }
1830
1841
  return encode_video_animation_limited(source, info, plan, options);
1831
1842
  }
@@ -40,6 +40,7 @@ struct TranscodeOptions {
40
40
  std::uint8_t alpha_threshold = 0U;
41
41
  std::uint8_t outline_width = 0U;
42
42
  RgbaColor outline_color{};
43
+ bool prefer_lossless = false;
43
44
  };
44
45
 
45
46
  struct StickerResult {
@@ -22,7 +22,10 @@ constexpr std::array<Byte, 4U> kResponseMagic = {'S', 'T', 'R', '1'};
22
22
  constexpr Byte kProtocolVersion = 1U;
23
23
  constexpr Byte kCommandTranscode = 1U;
24
24
  constexpr Byte kTrimFlag = 1U << 0U;
25
- constexpr Byte kUnsupportedRequestFlags = static_cast<Byte>(~kTrimFlag);
25
+ constexpr Byte kPreferLosslessFlag = 1U << 1U;
26
+ constexpr Byte kSupportedRequestFlags = kTrimFlag | kPreferLosslessFlag;
27
+ constexpr Byte kUnsupportedRequestFlags =
28
+ static_cast<Byte>(~kSupportedRequestFlags);
26
29
  constexpr std::size_t kRequestHeaderSize = 18U;
27
30
  constexpr std::size_t kResponseHeaderSize = 26U;
28
31
  constexpr std::size_t kMaximumFrameBytes =
@@ -166,6 +169,7 @@ void write_u32(
166
169
  .blue = frame[12U],
167
170
  .alpha = frame[13U],
168
171
  },
172
+ .prefer_lossless = (flags & kPreferLosslessFlag) != 0,
169
173
  },
170
174
  .input = std::vector<Byte>(
171
175
  frame.begin() + static_cast<std::ptrdiff_t>(kRequestHeaderSize),
package/src/native.ts CHANGED
@@ -8,6 +8,7 @@ const RESPONSE_MAGIC = "STR1";
8
8
  const PROTOCOL_VERSION = 1;
9
9
  const COMMAND_TRANSCODE = 1;
10
10
  const TRIM_FLAG = 1 << 0;
11
+ const PREFER_LOSSLESS_FLAG = 1 << 1;
11
12
  const REQUEST_HEADER_BYTES = 18;
12
13
  const RESPONSE_HEADER_BYTES = 26;
13
14
  const FRAME_LENGTH_BYTES = 4;
@@ -21,6 +22,15 @@ const textDecoder = new TextDecoder("utf-8", { fatal: true });
21
22
 
22
23
  type WorkerProcess = Bun.Subprocess<"pipe", "pipe", "ignore">;
23
24
 
25
+ type Uint8ArrayReadResult =
26
+ | { readonly done: true; readonly value?: Uint8Array }
27
+ | { readonly done: false; readonly value: Uint8Array };
28
+
29
+ interface Uint8ArrayReader {
30
+ read(): Promise<Uint8ArrayReadResult>;
31
+ cancel(reason?: unknown): Promise<void>;
32
+ }
33
+
24
34
  interface WorkerSession {
25
35
  readonly process: WorkerProcess;
26
36
  readonly stdin: WorkerProcess["stdin"];
@@ -53,6 +63,12 @@ export interface NativeStickerTranscodeOptions {
53
63
  readonly alphaThreshold?: number;
54
64
  /** Optional outline rendered behind the source image. */
55
65
  readonly outline?: OutlineOptions;
66
+ /**
67
+ * Attempt lossless WebP output before lossy encoding for static results.
68
+ * Defaults to false because it can take substantially longer for transparent
69
+ * artwork and still falls back to lossy output when it exceeds the size limit.
70
+ */
71
+ readonly preferLossless?: boolean;
56
72
  /**
57
73
  * Per-request deadline, including writes and reading the complete response.
58
74
  * A timed-out worker is killed and a fresh worker is used for a later call.
@@ -126,7 +142,7 @@ class WorkerProtocolError extends NativeStickerWorkerTransportError {
126
142
  }
127
143
 
128
144
  class BufferedReader {
129
- readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
145
+ readonly #reader: Uint8ArrayReader;
130
146
  #current: Uint8Array | undefined;
131
147
  #offset = 0;
132
148
 
@@ -249,6 +265,12 @@ function encodeRequest(
249
265
  if (options.trim !== undefined && typeof options.trim !== "boolean") {
250
266
  throw new TypeError("trim must be a boolean.");
251
267
  }
268
+ if (
269
+ options.preferLossless !== undefined
270
+ && typeof options.preferLossless !== "boolean"
271
+ ) {
272
+ throw new TypeError("preferLossless must be a boolean.");
273
+ }
252
274
 
253
275
  const input = sourceBytes(source);
254
276
  if (input.byteLength > MAXIMUM_SOURCE_BYTES) {
@@ -277,7 +299,9 @@ function encodeRequest(
277
299
  frame[header + 3] = REQUEST_MAGIC.charCodeAt(3);
278
300
  frame[header + 4] = PROTOCOL_VERSION;
279
301
  frame[header + 5] = COMMAND_TRANSCODE;
280
- frame[header + 6] = (options.trim ?? true) ? TRIM_FLAG : 0;
302
+ frame[header + 6] =
303
+ ((options.trim ?? true) ? TRIM_FLAG : 0)
304
+ | ((options.preferLossless ?? false) ? PREFER_LOSSLESS_FLAG : 0);
281
305
  frame[header + 7] = 0;
282
306
  frame[header + 8] = alphaThreshold;
283
307
  frame[header + 9] = outlineWidth;