dsh-read-animation 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,103 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ Initial version.
6
+
7
+ ### Fixed after an adversarial audit
8
+
9
+ Four independent reviews (blast radius, plugin code, packaging and lifecycle, then
10
+ an adversarial pass over every high-severity finding) ran before this was
11
+ published. What they found, and what changed:
12
+
13
+ - **The frame sheet's `variantId` was not a content digest.** It was a hand-rolled
14
+ `sha256:filmanim…` string, and the DeepSeek adapter validates persisted ids
15
+ against `/^sha256:[0-9a-f]{64}$/` when it loads its upload index — then swallows
16
+ the failure into an *empty* index. The effect was not a visible error: it
17
+ discarded every other session's cached file id and re-uploaded every image.
18
+ Reproduced against the real exported index class (plugin id: written 1, reloaded
19
+ 0; conforming id: 1/1). It is now the SHA-256 of the bytes being returned, which
20
+ also stops one on-disk cache entry from aliasing two different byte streams.
21
+ - **The degraded path was worse than the missing feature.** The store reaches the
22
+ frame it re-encodes through its own `readImage` call, which this plugin wraps, so
23
+ falling back to the store's request read could hand the model the *whole
24
+ animation* where it expects one frame. The request path now suspends the
25
+ animation substitution around that call, and reads its own tiles through the
26
+ unwrapped read. Pinned by an integration check that breaks a stored frame and
27
+ asserts the fallback is exactly one frame.
28
+ - **No decoded-pixel ceiling on the `read_animation` path.** `limitInputPixels`
29
+ was disabled with nothing in its place: a 60 KB file declaring a 9000x9000
30
+ canvas was refused by the deployment's own attachment store and decoded here
31
+ anyway, at about 310 MiB. Both the probe and the frame extraction now carry the
32
+ deployment's `imageLimits.maxImagePixels`.
33
+ - **The sheet cache ignored the size the caller asked for**, so a caller with an
34
+ 8 KiB budget could be served a 28 KB sheet built for a 4 MiB one. The target is
35
+ now part of the cache key, and a tile is never larger than a single frame would
36
+ have been for that caller.
37
+ - **The measured header wrap could still be defeated.** A floor of 16 characters
38
+ kept the loop from reaching a budget that fit, so on a narrow canvas it gave up
39
+ and returned ink wider than the sheet. The floor is now low enough to be
40
+ satisfiable, and a canvas too narrow for legible text gets no header at all.
41
+ - **The canvas was composed before it was shrunk**, building a 6156x6306 (about
42
+ 111 MiB) image to throw away. Tile size and grid are settled first.
43
+ - **Torn state files could poison the next record.** A crash part way through an
44
+ append left a line with no newline, so the following record concatenated onto it
45
+ and became unreadable too. A torn tail is now detected and compacted away.
46
+ - **The store's bound applied to memory but not to what was read back**, and the
47
+ log was never compacted. Both fixed; the file is rewritten when it grows past
48
+ twice the entries worth keeping.
49
+ - **Teardown was last-writer-wins.** Each install captured whatever was on the
50
+ method, so disposing one left another's patch removed and could reinstall a
51
+ disposed closure as a permanent own property. Three wrappers now share one
52
+ installer that resolves its delegate per call, removes cleanly (deleting an
53
+ inherited property rather than leaving a shadow behind), and reports whether it
54
+ actually took effect — the previous identity check compared a value that proxy
55
+ reads always re-wrap, so it could never detect a failed install.
56
+ - **`npm test` did not run the invariant checks.** The no-`@deepseek-ai`-import
57
+ rule and the bundle/manifest consistency check lived only in the integration
58
+ harness, which prints SKIP and exits 0 without `DSH_HARNESS_MODULES`. Both now
59
+ run in `npm test`, which is also the `prepublishOnly` gate.
60
+ - **`files[]` omitted `scripts/`** while `package.json` advertised two npm scripts
61
+ that live there, so the published package's own scripts were broken.
62
+
63
+ - **A pasted animation keeps playing in the UI, and the message is left alone.**
64
+ The browser already uploads an animated GIF/WebP byte-for-byte and labelled as
65
+ such; the Host flattens it to a single
66
+ frame during prompt admission and keeps no copy of the source. The plugin wraps
67
+ `ctx.attachments.admitPromptContent` to capture the frames and the original file
68
+ at that moment, and `ctx.attachments.readImage` so the UI is served the animation
69
+ itself (the browser animates whatever bytes that one read returns). What you send
70
+ is what you send: the chat message is unchanged.
71
+ - **The model now receives a labelled sheet of the frames.** The delivery step is
72
+ `ctx.attachments.readImageRequest(ref, target, signal)`, the method each adapter
73
+ calls to build its own copy of one image. It answers with exactly one image, so
74
+ the frames are composed into a grid chosen to be as square as the sheet budget
75
+ allows — every tile at its native size, with the frame index, duration and start
76
+ time printed under each, and the animation's own facts in a wrapped header. The
77
+ sheet is built once per animation and cached.
78
+ - Rewriting the request from an `llm/stream` listener is **not** used and would not
79
+ work: a loop-built request arrives deep-frozen ("listeners read it, never rewrite
80
+ it"), so that earlier attempt silently did nothing.
81
+ - Captured frames and the original are persisted per `$DSH_HOME` in
82
+ `read-animation/v1/pasted.jsonl`, keyed by the message's attachment id, so a
83
+ session resumed in a new process still displays — and still delivers — an
84
+ animation pasted before the restart.
85
+ - A frame's index and start time ride in its attachment display name
86
+ (`x.gif#frame16@1960ms`), which the harness renders beside the picture once the
87
+ frames are delivered — so the timing never has to be repeated as message text.
88
+ (An earlier version of this feature put a header plus one caption per frame into
89
+ the user's own message; consecutive text parts are concatenated by the UI with
90
+ no separator, so a one-image message became an unreadable wall.)
91
+ - `read_animation` tool: decodes an animated GIF or animated WebP, samples frames
92
+ across the timeline (or by explicit index / instant), commits each frame to the
93
+ attachment store, and returns a header plus one captioned image per frame.
94
+ - Refuses animated PNG by name instead of silently returning its first frame,
95
+ which is what the host image library does with one.
96
+ - Refuses static images and points at `read_image`; answers a single-frame GIF or
97
+ WebP with a note that it is not animated.
98
+ - Discloses decode-time substitutions in the model-facing text: zero frame
99
+ delays treated as 100 ms, missing delays, transparency flattened onto white.
100
+ - The package imports no `@deepseek-ai/*` module at runtime, so it loads both as
101
+ an installed package and as a `link:`ed checkout. The compiled schemas ship as
102
+ literals beside their authoring specs, and the integration harness keeps the
103
+ two in step.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NeoHotaru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,321 @@
1
- # Temporary Holding Version
1
+ # dsh-read-animation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A DeepSeek Harness plugin that lets the agent **actually see an animated GIF**.
4
+
5
+ `read_image` on a GIF gives the model one picture — the first frame — and no
6
+ signal that the other frames existed. This plugin decodes every frame and hands
7
+ the model a labelled sequence of real frames sampled across the animation's
8
+ timeline.
9
+
10
+ ```sh
11
+ dsh plugin --profile web add dsh-read-animation
12
+ ```
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ DeepSeek Harness normalizes every stored image to a single-frame 8-bit sRGB
19
+ raster before it reaches a model, and it does this to animations too.
20
+ `@deepseek-ai/dsh-attachment-local` excludes `image/gif` from its pass-through
21
+ path and re-encodes through a pipeline that never sets `animated` or `page`, so
22
+ `sharp` returns page 0 and the rest are dropped on the floor.
23
+
24
+ Measured, on this machine, against an 8-frame GIF:
25
+
26
+ ```
27
+ metadata: {"format":"gif","width":240,"height":160,"pages":8,
28
+ "delay":[80,80,80,80,80,80,80,80],"loop":0}
29
+ default -> 240x160 pages=1 ← what read_image delivers
30
+ animated -> 240x1280 pages=1 ← all eight frames, when asked for
31
+ ```
32
+
33
+ No layer above the read step can repair this. By the time a vision plugin, an
34
+ image router, or the LLM adapter sees the image it is already a single frame —
35
+ which is why the growing ecosystem of DSH vision plugins
36
+ ([`dsh-multimodal`](https://github.com/SCT192221/dsh-multimodal),
37
+ [`dsh-mmroute`](https://github.com/jmxsxwyzjdwl/dsh-mmroute),
38
+ [`dsh-vision-mix`](https://github.com/haiziyao/dsh-vision-mix),
39
+ [`dsh-tool-plus`](https://github.com/xiaoso456/dsh-tool-plus), …) all say they
40
+ "support PNG/JPEG/WebP/GIF" without any of them showing you motion. The
41
+ animation is still intact in exactly one place: the file. So the tool lives
42
+ there.
43
+
44
+ Animated PNG has the same disease with a worse prognosis: libvips 8.18.7 does
45
+ not read `acTL`/`fcTL`/`fdAT` at all, reports an APNG as a one-page PNG, and
46
+ silently discards every frame after the first. This plugin detects that case from
47
+ the chunk stream and refuses it by name instead of returning frame 0.
48
+
49
+ ## Two ways in, both handled
50
+
51
+ **Pasting into the chat box.** That path is already clean on the browser side: the
52
+ paste handler hands the clipboard's own `File` straight to the upload path, and
53
+ that path encodes `image/gif` and `image/webp` unchanged (`imageMediaType()`
54
+ accepts both and returns them as-is) — no canvas, no `drawImage`, no `toBlob`.
55
+ **The bytes reach the Host whole.** It is the Host that destroys them:
56
+ `admitPromptContent` runs every image through the single-frame normalizer and
57
+ keeps no copy of the source.
58
+
59
+ `admitPromptContent` is therefore the last moment the original bytes exist. The
60
+ plugin wraps it, decodes the animation and stores the frames — then **returns the
61
+ original result untouched**:
62
+
63
+ ```
64
+ you paste 1 animation
65
+ ├─ the message: unchanged — the picture you sent, not one word added
66
+ ├─ the UI: the animation itself, played from the original bytes
67
+ └─ the model: one sheet of its frames, each labelled with index and time
68
+ ```
69
+
70
+ **What you send is what you send.** Nothing is added to the message you wrote,
71
+ and it does not turn into eight stills.
72
+
73
+ **And it keeps playing in the UI.** The Web UI has exactly one way to display a
74
+ durable image: `session.readAttachment(id)` → the Host's
75
+ `ctx.attachments.readImage(ref)` →
76
+ `URL.createObjectURL(new Blob([bytes], { type: attachment.mediaType }))`. Both the
77
+ bytes and the media type come from that one host call, and the browser animates
78
+ whatever it is handed. So the plugin serves the original at that **display read** —
79
+ no client plugin, no media route, no second authorization path. The single frame
80
+ in the store is only a lossy artifact of a normalizer that cannot represent
81
+ motion; handing back the file the person actually pasted is the more truthful
82
+ answer to "read this stored image", and the other two callers of that layer (ACP
83
+ image blocks, session-log export) are better with an animation too.
84
+
85
+ Each captured frame carries its index and start time in its attachment display
86
+ name (`x.gif#frame16@1960ms`), which the harness renders to the model as
87
+ `Image "<name>" …` — so the timing is already attached to the frames, waiting
88
+ for the delivery step below.
89
+
90
+ **The display path and the model's are independent**: the UI is served the
91
+ original bytes by `readImage`, while the model's copy comes from
92
+ `readImageRequest`, which composes the sheet.
93
+
94
+ > **Where the frames are delivered**: `readImageRequest(ref, target, signal)` is
95
+ > the method each adapter calls to build its own copy of one image
96
+ > (`dsh-llm-deepseek` and `dsh-llm-pi-ai` both do), so it is the seam that reaches
97
+ > the model. It answers with exactly one image, so the frames are composed into one
98
+ > labelled sheet: a 4x2 grid for eight samples of a phone-shaped animation, every
99
+ > tile at its native size, with the frame index, duration and start time printed
100
+ > under each. Nothing is re-derived per request — the same stored attachment always
101
+ > produces the same sheet — and the sheet is built once and cached.
102
+ >
103
+ > The route that looks obvious, rewriting the request from an `llm/stream`
104
+ > listener, is closed by design: a loop-built request "arrives deep-frozen
105
+ > (mutation throws): its content is a pure function of the session log", so
106
+ > listeners may read it but never rewrite it. An earlier version of this plugin
107
+ > tried exactly that and silently did nothing, because the probe behind it used a
108
+ > hand-built request, which is the one case that documentation exempts.
109
+
110
+ **Files.** Animations in the workspace, or attached as files, still exist on
111
+ disk byte-for-byte; the `read_animation` tool below reads those.
112
+
113
+ ## What it does
114
+
115
+ `read_animation(path, …)` returns one text header plus one captioned image per
116
+ sampled frame:
117
+
118
+ ```
119
+ <path>D:\work\dance.gif</path>
120
+ <type>animation</type>
121
+ <content>
122
+ image/gif animation, 480x270 px, 24 frames, 1200 ms total, ~20 fps, loops forever.
123
+ Attached below are 6 frames sampled evenly in time from this animation. They are
124
+ consecutive moments of the SAME animation, in time order, not separate images;
125
+ each is preceded by its frame index and start time.
126
+ Only frames 0, 4, 9, 13, 18, 23 of 24 were sampled, so any motion between two
127
+ attached frames is not visible. Re-read with explicit "frames" indices to inspect
128
+ a specific moment, or a higher "max_frames" to sample more densely.
129
+ </content>
130
+ ```
131
+
132
+ …then, for each frame:
133
+
134
+ ```
135
+ frame 9 of 24 (sample 3) · starts at 450 ms · displayed for 50 ms
136
+ [ image ]
137
+ ```
138
+
139
+ Everything the model needs to reason about *motion* is in that text: how long the
140
+ animation runs, how fast it plays, whether it loops, which frames it is looking
141
+ at, and — stated outright, because it is the easiest thing to get wrong — that
142
+ the gaps are gaps.
143
+
144
+ ## Install
145
+
146
+ ```sh
147
+ dsh plugin --profile web add dsh-read-animation
148
+ ```
149
+
150
+ Replace `web` with your profile name. **Not on npm yet** — until it is, install
151
+ straight from the repository:
152
+
153
+ ```sh
154
+ dsh plugin --profile web add github:NeoHotaru/dsh-read-animation
155
+ ```
156
+
157
+ That route is verified end to end: it installs, `dsh plugin add` registers the
158
+ bundle, and the `sharp` native binary resolves from the package's own
159
+ `node_modules`. (A git spec copies the whole repository, tests and fixtures
160
+ included, which is why the npm package above is the nicer install once it
161
+ exists.)
162
+
163
+ Or from a checkout:
164
+
165
+ ```sh
166
+ cd dsh-read-animation
167
+ npm install # fetches sharp
168
+ dsh plugin --profile web add link:$PWD
169
+ ```
170
+
171
+ The same spec works in the Web sidebar's Plugins page, and in Settings the plugin
172
+ list is read-only. Restart the running instance afterwards: plugin rows take
173
+ effect on the next process, and a `link:`ed checkout is loaded once at startup.
174
+
175
+ ## Usage
176
+
177
+ The model reaches for it on its own for a `.gif`, because the tool description
178
+ says to. You can also ask directly:
179
+
180
+ > what happens in this GIF? — `animations/loading-spinner.gif`
181
+
182
+ Arguments:
183
+
184
+ | argument | meaning |
185
+ |---|---|
186
+ | `file_path` | the animation to read (required) |
187
+ | `frames` | explicit 0-based indices, in the order given — up to 16 |
188
+ | `at` | millisecond instants to sample instead of an even spread |
189
+ | `max_frames` | how many frames to sample (default 8, maximum 16) |
190
+ | `max_edge` | long-edge size of each returned frame in px (default 640) |
191
+
192
+ A useful loop, when the first read is not enough detail:
193
+
194
+ ```
195
+ read_animation { file_path: "dance.gif" } → 8 frames across the timeline
196
+ read_animation { file_path: "dance.gif",
197
+ frames: [11, 12, 13], max_edge: 1024 } → those three, full size
198
+ ```
199
+
200
+ ## Configuration
201
+
202
+ Optional, in the plugin row of your profile's `cordis.patch.yml`:
203
+
204
+ ```yaml
205
+ - id: read-animation
206
+ name: 'dsh-read-animation'
207
+ config:
208
+ maxFrames: 8 # default frames per call, 1..16
209
+ maxEdge: 640 # default long-edge cap, 64..2048
210
+ background: white # "white" flattens transparency, "keep" preserves it
211
+ maxInputBytes: 67108864 # refuse animations larger than this
212
+ expandPastedAnimations: true # a pasted animation keeps playing in the UI and reaches the model as a frame sheet
213
+ ```
214
+
215
+ ## What it will not do
216
+
217
+ - **Video.** MP4/WebM need ffmpeg and a different decode path. Use
218
+ [`dsh-video-tools`](https://github.com/PiedPiper911/dsh-video-tools).
219
+ - **APNG.** Not decodable by the host image library; refused with an explanation
220
+ (see above).
221
+ - **Read a static image.** It says so and points at `read_image`. A single-frame
222
+ GIF or WebP is answered, with a note that it is not animated.
223
+ - **Guess.** When the sampler returns fewer frames than you asked for, the header
224
+ says which ones and why.
225
+
226
+ ## How it behaves under the hood
227
+
228
+ Frames are committed through `ctx.attachments.saveImages`, the same store every
229
+ other harness image goes through. So frames are content-addressed (two identical
230
+ frames in one GIF cost one attachment; re-reading a file reuses what is already
231
+ stored), durable across a session replay, and subject to the deployment's own
232
+ image limits. Frames are resized to the target edge and re-encoded as PNG before
233
+ storage, so the harness's normalizer has nothing left to do to them.
234
+
235
+ Reading goes through `ctx.fs.readBytes` and emits `fs/observed`, so the sandbox
236
+ and the read-before-write policy apply exactly as they do to `read_image`. The
237
+ tool refuses to run when the resolved model route does not declare image input,
238
+ for the same reason `read_image` does.
239
+
240
+ **A pasted animation** is handled in three steps, none of which touch your
241
+ message: **capture** at admission (`ctx.attachments.admitPromptContent` is the last
242
+ moment the original bytes exist), **display** at `ctx.attachments.readImage` (the
243
+ one read the UI makes, served the original so it plays), and **delivery** at
244
+ `ctx.attachments.readImageRequest` (the model's copy, composed into one labelled
245
+ sheet). All three are **monkey-patches of core service methods**, so they do
246
+ nothing at all the moment anything is not as expected — a missing method, a failed
247
+ assignment, a decode that throws, an original that cannot be read back, a batch
248
+ with no room. The caller always receives exactly what it would have received
249
+ anyway. Captured frames and the original file are recorded in
250
+ `$DSH_HOME/read-animation/v1/pasted.jsonl`, keyed by the attachment id the message
251
+ carries, so a session resumed in a new process still displays — and still expands
252
+ — an animation pasted before the restart. Delivery swaps references only — at that
253
+ layer an image is still `{type: 'image', attachment}` — and never touches bytes.
254
+
255
+ ### Design notes for contributors
256
+
257
+ Two decisions are worth knowing before you change anything.
258
+
259
+ **Frames are extracted one at a time with `sharp(buf, { page: i })`, not by
260
+ decoding the animation as one tall strip and slicing it.** Both were measured on
261
+ a 600×400, 60-frame GIF: the strip path costs 54.9 MiB resident and is *slower*
262
+ here (228 ms for 8 frames at a 512 px target, because it resizes all 60 frames)
263
+ than per-frame extraction (182 ms, one frame resident at a time). The correctness
264
+ claim this rests on — that `{ page: i }` is byte-identical to frame `i` of the
265
+ composited strip — is pinned by a test.
266
+
267
+ **The package imports no `@deepseek-ai/*` at runtime.** The harness's own tools
268
+ build their definitions with `defineTool`, which compiles a schema spec and wraps
269
+ the body with argument validation. That is right for a plugin installed *as a
270
+ package*, where pnpm places it under `$DSH_HOME/profiles/<name>/node_modules/`
271
+ and `@deepseek-ai/*` resolves by walking up to the profile's hoisted tree. It is
272
+ wrong for a plugin *linked from a working copy*: a `link:`ed package loads
273
+ through its real path, so its imports resolve upward from the checkout, where no
274
+ `@deepseek-ai/*` exists — measured, by importing the entry with `node_modules`
275
+ absent (`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-tools'`).
276
+ `lib/tool.js` therefore ships the compiled JSON Schema as literals beside the
277
+ authoring spec, and the integration harness asserts the real `defineTool` still
278
+ derives exactly those literals from that spec. Edit the spec, run
279
+ `npm run derive-schema`, paste the result.
280
+
281
+ ## Development
282
+
283
+ ```sh
284
+ npm install
285
+ npm test # decoder, sampling, envelope, wrapping, store, layout, arguments
286
+ npm run test:integration # real dsh-fs, dsh-attachments, and dsh-tools, in-process
287
+ npm run fixtures # regenerate test/fixtures (needs Python 3 + Pillow)
288
+ ```
289
+
290
+ `npm test` needs nothing but Node and the installed `sharp`, and it is the gate
291
+ `npm publish` runs. It also holds the two invariants that used to live only in the
292
+ integration harness — the no-`@deepseek-ai`-import rule and the bundle's
293
+ consistency with `package.json` — because that harness prints SKIP and exits 0
294
+ when `DSH_HARNESS_MODULES` is unset, so "the integration suite is green" could
295
+ otherwise mean nothing had been checked.
296
+
297
+ `npm run test:integration` boots the genuine `dsh-fs-local`,
298
+ `dsh-attachment-local`, `dsh-system-prompt`, and `dsh-tools` plugins in-process,
299
+ registers the tool, and executes it. It exists to prove the one thing no unit
300
+ test can reach and the whole design depends on: that a single tool result
301
+ carrying *several* image blocks survives the real registry and the real
302
+ attachment store. It also drives the pasted-animation path end to end — a real
303
+ `llm.stream` dispatch, a deep-frozen request, and the request image the adapter
304
+ receives — because three separate bugs in this plugin were invisible to
305
+ everything that did not go through that path. Only the `llm` service and the
306
+ agent object are stubbed, both because they need a live model session. Point it
307
+ at your harness install:
308
+
309
+ ```sh
310
+ DSH_HARNESS_MODULES="$(npm root -g)/@deepseek-ai/dsh/node_modules" npm run test:integration
311
+ ```
312
+
313
+ On a machine that has no `sharp` reachable from the checkout,
314
+ `DSH_READ_ANIMATION_SHARP=<path to a loadable sharp>` overrides resolution.
315
+
316
+ The fixtures are committed, so the suite runs without Python; `make-fixtures.py`
317
+ regenerates them and documents what each one is for.
318
+
319
+ ## License
320
+
321
+ MIT. See [THIRD_PARTY.md](THIRD_PARTY.md) for sharp and libvips.