dsh-read-animation 0.1.1 → 0.1.2

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 CHANGED
@@ -1,124 +1,169 @@
1
- # Changelog
2
-
3
- ## 0.1.1 — 2026-10-14
4
-
5
- Fixed after the first release, from what a live session reported back.
6
-
7
- - **`read_animation` follows the path it advertises.** Every image a model receives
8
- is shown as a normalized copy at `attachments/v1/objects/<2>/<digest>`, and that
9
- is the path a model reaches for when it wants to look at the picture again. For a
10
- pasted animation that copy is a single frame, so the tool answered "this picture
11
- has one frame": true of that file, and useless as an answer. The tool now resolves
12
- that path to the animation this plugin kept, and reports the file the bytes
13
- actually came from. A bare `sha256:<digest>` is accepted too.
14
- - **The sheet says where the animation lives.** Its header ends with the path the
15
- store keeps the original at, so a model that wants more than the eight samples can
16
- read that path with explicit frame indices instead of searching the attachment
17
- store for the file behind the picture it was sent.
18
-
19
- ## 0.1.0 — 2026-10-14
20
-
21
- First release.
22
-
23
- Note for anyone publishing a later version of this package: npm routes the first
24
- release of a brand-new package name through staged publishing, and the registry
25
- carries a public `0.0.0-stage` placeholder while the real version is processed. The
26
- real version appears on its own a couple of minutes later; nothing needs approving.
27
-
28
- ### Fixed after an adversarial audit
29
-
30
- Four independent reviews (blast radius, plugin code, packaging and lifecycle, then
31
- an adversarial pass over every high-severity finding) ran before this was
32
- published. What they found, and what changed:
33
-
34
- - **The frame sheet's `variantId` was not a content digest.** It was a hand-rolled
35
- `sha256:filmanim…` string, and the DeepSeek adapter validates persisted ids
36
- against `/^sha256:[0-9a-f]{64}$/` when it loads its upload index — then swallows
37
- the failure into an *empty* index. The effect was not a visible error: it
38
- discarded every other session's cached file id and re-uploaded every image.
39
- Reproduced against the real exported index class (plugin id: written 1, reloaded
40
- 0; conforming id: 1/1). It is now the SHA-256 of the bytes being returned, which
41
- also stops one on-disk cache entry from aliasing two different byte streams.
42
- - **The degraded path was worse than the missing feature.** The store reaches the
43
- frame it re-encodes through its own `readImage` call, which this plugin wraps, so
44
- falling back to the store's request read could hand the model the *whole
45
- animation* where it expects one frame. The request path now suspends the
46
- animation substitution around that call, and reads its own tiles through the
47
- unwrapped read. Pinned by an integration check that breaks a stored frame and
48
- asserts the fallback is exactly one frame.
49
- - **No decoded-pixel ceiling on the `read_animation` path.** `limitInputPixels`
50
- was disabled with nothing in its place: a 60 KB file declaring a 9000x9000
51
- canvas was refused by the deployment's own attachment store and decoded here
52
- anyway, at about 310 MiB. Both the probe and the frame extraction now carry the
53
- deployment's `imageLimits.maxImagePixels`.
54
- - **The sheet cache ignored the size the caller asked for**, so a caller with an
55
- 8 KiB budget could be served a 28 KB sheet built for a 4 MiB one. The target is
56
- now part of the cache key, and a tile is never larger than a single frame would
57
- have been for that caller.
58
- - **The measured header wrap could still be defeated.** A floor of 16 characters
59
- kept the loop from reaching a budget that fit, so on a narrow canvas it gave up
60
- and returned ink wider than the sheet. The floor is now low enough to be
61
- satisfiable, and a canvas too narrow for legible text gets no header at all.
62
- - **The canvas was composed before it was shrunk**, building a 6156x6306 (about
63
- 111 MiB) image to throw away. Tile size and grid are settled first.
64
- - **Torn state files could poison the next record.** A crash part way through an
65
- append left a line with no newline, so the following record concatenated onto it
66
- and became unreadable too. A torn tail is now detected and compacted away.
67
- - **The store's bound applied to memory but not to what was read back**, and the
68
- log was never compacted. Both fixed; the file is rewritten when it grows past
69
- twice the entries worth keeping.
70
- - **Teardown was last-writer-wins.** Each install captured whatever was on the
71
- method, so disposing one left another's patch removed and could reinstall a
72
- disposed closure as a permanent own property. Three wrappers now share one
73
- installer that resolves its delegate per call, removes cleanly (deleting an
74
- inherited property rather than leaving a shadow behind), and reports whether it
75
- actually took effect — the previous identity check compared a value that proxy
76
- reads always re-wrap, so it could never detect a failed install.
77
- - **`npm test` did not run the invariant checks.** The no-`@deepseek-ai`-import
78
- rule and the bundle/manifest consistency check lived only in the integration
79
- harness, which prints SKIP and exits 0 without `DSH_HARNESS_MODULES`. Both now
80
- run in `npm test`, which is also the `prepublishOnly` gate.
81
- - **`files[]` omitted `scripts/`** while `package.json` advertised two npm scripts
82
- that live there, so the published package's own scripts were broken.
83
-
84
- - **A pasted animation keeps playing in the UI, and the message is left alone.**
85
- The browser already uploads an animated GIF/WebP byte-for-byte and labelled as
86
- such; the Host flattens it to a single
87
- frame during prompt admission and keeps no copy of the source. The plugin wraps
88
- `ctx.attachments.admitPromptContent` to capture the frames and the original file
89
- at that moment, and `ctx.attachments.readImage` so the UI is served the animation
90
- itself (the browser animates whatever bytes that one read returns). What you send
91
- is what you send: the chat message is unchanged.
92
- - **The model now receives a labelled sheet of the frames.** The delivery step is
93
- `ctx.attachments.readImageRequest(ref, target, signal)`, the method each adapter
94
- calls to build its own copy of one image. It answers with exactly one image, so
95
- the frames are composed into a grid chosen to be as square as the sheet budget
96
- allows — every tile at its native size, with the frame index, duration and start
97
- time printed under each, and the animation's own facts in a wrapped header. The
98
- sheet is built once per animation and cached.
99
- - Rewriting the request from an `llm/stream` listener is **not** used and would not
100
- work: a loop-built request arrives deep-frozen ("listeners read it, never rewrite
101
- it"), so that earlier attempt silently did nothing.
102
- - Captured frames and the original are persisted per `$DSH_HOME` in
103
- `read-animation/v1/pasted.jsonl`, keyed by the message's attachment id, so a
104
- session resumed in a new process still displays — and still delivers — an
105
- animation pasted before the restart.
106
- - A frame's index and start time ride in its attachment display name
107
- (`x.gif#frame16@1960ms`), which the harness renders beside the picture once the
108
- frames are delivered — so the timing never has to be repeated as message text.
109
- (An earlier version of this feature put a header plus one caption per frame into
110
- the user's own message; consecutive text parts are concatenated by the UI with
111
- no separator, so a one-image message became an unreadable wall.)
112
- - `read_animation` tool: decodes an animated GIF or animated WebP, samples frames
113
- across the timeline (or by explicit index / instant), commits each frame to the
114
- attachment store, and returns a header plus one captioned image per frame.
115
- - Refuses animated PNG by name instead of silently returning its first frame,
116
- which is what the host image library does with one.
117
- - Refuses static images and points at `read_image`; answers a single-frame GIF or
118
- WebP with a note that it is not animated.
119
- - Discloses decode-time substitutions in the model-facing text: zero frame
120
- delays treated as 100 ms, missing delays, transparency flattened onto white.
121
- - The package imports no `@deepseek-ai/*` module at runtime, so it loads both as
122
- an installed package and as a `link:`ed checkout. The compiled schemas ship as
123
- literals beside their authoring specs, and the integration harness keeps the
124
- two in step.
1
+ # Changelog
2
+
3
+ ## 0.1.2 — 2026-11-08
4
+
5
+ - Fix [#7](https://github.com/NeoHotaru/dsh-read-animation/issues/7). Capture
6
+ uploaded GIF and animated WebP bytes at `attachments.saveImages`. DSH 0.2.2
7
+ normalizes uploads before prompt admission, so its stored references previously
8
+ bypassed capture. Original animation display, model frame sheets, and
9
+ normalized-copy reads now use the captured source.
10
+ - Add raw-upload regressions through the real attachment store and, on DSH
11
+ 0.2.2, its upload service and receipt resolution. Update the integration fixture
12
+ for the separate native tool executor and batch execution API.
13
+
14
+
15
+ - **The supported DSH range is measured rather than assumed.** The plugin wraps
16
+ internal service methods, and `engines.dsh` claimed `>=0.1.7-alpha.1` on the
17
+ strength of one working install. The integration suite now runs against
18
+ `0.1.3-alpha.2` (the oldest release exposing every member), `0.1.7-alpha.2`,
19
+ `0.2.0-rc.2` — which is `latest` — and `0.2.1-alpha.3`, all 38/38, and
20
+ `scripts/check-dsh-compat.py` reads the type declarations of all 32 published
21
+ releases to find the floor: **0.1.3-alpha.2**. See
22
+ [COMPATIBILITY.md](COMPATIBILITY.md), including what is *not* claimed.
23
+ - **A host missing a member now says so.** Nothing checked the members the plugin
24
+ calls before calling them: a host with `readImage` but no `saveImages` mounted
25
+ cleanly and then threw a `TypeError` from inside the tool. Mount reports every
26
+ missing member with the release it arrived in, the tool names the reason in its
27
+ own error, and a test covers the check itself.
28
+ - **`preview-sheet` printed a path that does not exist.** It spelled the store
29
+ layout as `files/<name>` where the store uses `files/<2>/<digest>/<name>`. The
30
+ plugin asks `fileHostPath` and was always right; only the script's second copy of
31
+ the layout could drift.
32
+ - **The integration harness needed `maxPixels` in a request target.** DSH
33
+ 0.1.3-alpha.2 rejects a target without it; later releases default it. A test
34
+ artefact, not a compatibility problem, but it meant the suite could not run on
35
+ the floor version at all.
36
+
37
+ ## 0.1.1 — 2026-10-14
38
+
39
+ Fixed after the first release, from what a live session reported back.
40
+
41
+ - **`read_animation` follows the path it advertises.** Every image a model receives
42
+ is shown as a normalized copy at `attachments/v1/objects/<2>/<digest>`, and that
43
+ is the path a model reaches for when it wants to look at the picture again. For a
44
+ pasted animation that copy is a single frame, so the tool answered "this picture
45
+ has one frame": true of that file, and useless as an answer. The tool now resolves
46
+ that path to the animation this plugin kept, and reports the file the bytes
47
+ actually came from. A bare `sha256:<digest>` is accepted too.
48
+ - **The sheet says where the animation lives.** Its header ends with the path the
49
+ store keeps the original at, so a model that wants more than the eight samples can
50
+ read that path with explicit frame indices instead of searching the attachment
51
+ store for the file behind the picture it was sent.
52
+
53
+ ## 0.1.0 — 2026-10-14
54
+
55
+ First release.
56
+
57
+ Note for anyone publishing a later version, because both releases so far looked like
58
+ failures while they were simply still in flight:
59
+
60
+ - `npm publish` answers `PUT … 202` and prints "Your package is being processed and
61
+ may take a few minutes to become available", then exits 0. For a brand-new package
62
+ name the registry also carries a public `0.0.0-stage` placeholder alongside it.
63
+ Nothing needs approving in either case.
64
+ - Measured on 0.1.1: the version appeared in the packument **2m41s** after the PUT,
65
+ and its tarball became downloadable **7m05s** after it. In between, the packument
66
+ either 404s or lists the version while its tarball still 404s.
67
+ - Check the tarball, not just `npm view versions`, and check it with a GET: a HEAD
68
+ request to the tarball URL answers 404 even once the tarball is live.
69
+ - A mirror lags further. `registry.npmmirror.com` still served 0.1.0 well after
70
+ 0.1.1 was live on npmjs.org, and a profile configured against it keeps installing
71
+ the previous version until it syncs.
72
+
73
+ ### Fixed after an adversarial audit
74
+
75
+ Four independent reviews (blast radius, plugin code, packaging and lifecycle, then
76
+ an adversarial pass over every high-severity finding) ran before this was
77
+ published. What they found, and what changed:
78
+
79
+ - **The frame sheet's `variantId` was not a content digest.** It was a hand-rolled
80
+ `sha256:filmanim…` string, and the DeepSeek adapter validates persisted ids
81
+ against `/^sha256:[0-9a-f]{64}$/` when it loads its upload index — then swallows
82
+ the failure into an *empty* index. The effect was not a visible error: it
83
+ discarded every other session's cached file id and re-uploaded every image.
84
+ Reproduced against the real exported index class (plugin id: written 1, reloaded
85
+ 0; conforming id: 1/1). It is now the SHA-256 of the bytes being returned, which
86
+ also stops one on-disk cache entry from aliasing two different byte streams.
87
+ - **The degraded path was worse than the missing feature.** The store reaches the
88
+ frame it re-encodes through its own `readImage` call, which this plugin wraps, so
89
+ falling back to the store's request read could hand the model the *whole
90
+ animation* where it expects one frame. The request path now suspends the
91
+ animation substitution around that call, and reads its own tiles through the
92
+ unwrapped read. Pinned by an integration check that breaks a stored frame and
93
+ asserts the fallback is exactly one frame.
94
+ - **No decoded-pixel ceiling on the `read_animation` path.** `limitInputPixels`
95
+ was disabled with nothing in its place: a 60 KB file declaring a 9000x9000
96
+ canvas was refused by the deployment's own attachment store and decoded here
97
+ anyway, at about 310 MiB. Both the probe and the frame extraction now carry the
98
+ deployment's `imageLimits.maxImagePixels`.
99
+ - **The sheet cache ignored the size the caller asked for**, so a caller with an
100
+ 8 KiB budget could be served a 28 KB sheet built for a 4 MiB one. The target is
101
+ now part of the cache key, and a tile is never larger than a single frame would
102
+ have been for that caller.
103
+ - **The measured header wrap could still be defeated.** A floor of 16 characters
104
+ kept the loop from reaching a budget that fit, so on a narrow canvas it gave up
105
+ and returned ink wider than the sheet. The floor is now low enough to be
106
+ satisfiable, and a canvas too narrow for legible text gets no header at all.
107
+ - **The canvas was composed before it was shrunk**, building a 6156x6306 (about
108
+ 111 MiB) image to throw away. Tile size and grid are settled first.
109
+ - **Torn state files could poison the next record.** A crash part way through an
110
+ append left a line with no newline, so the following record concatenated onto it
111
+ and became unreadable too. A torn tail is now detected and compacted away.
112
+ - **The store's bound applied to memory but not to what was read back**, and the
113
+ log was never compacted. Both fixed; the file is rewritten when it grows past
114
+ twice the entries worth keeping.
115
+ - **Teardown was last-writer-wins.** Each install captured whatever was on the
116
+ method, so disposing one left another's patch removed and could reinstall a
117
+ disposed closure as a permanent own property. Three wrappers now share one
118
+ installer that resolves its delegate per call, removes cleanly (deleting an
119
+ inherited property rather than leaving a shadow behind), and reports whether it
120
+ actually took effect — the previous identity check compared a value that proxy
121
+ reads always re-wrap, so it could never detect a failed install.
122
+ - **`npm test` did not run the invariant checks.** The no-`@deepseek-ai`-import
123
+ rule and the bundle/manifest consistency check lived only in the integration
124
+ harness, which prints SKIP and exits 0 without `DSH_HARNESS_MODULES`. Both now
125
+ run in `npm test`, which is also the `prepublishOnly` gate.
126
+ - **`files[]` omitted `scripts/`** while `package.json` advertised two npm scripts
127
+ that live there, so the published package's own scripts were broken.
128
+
129
+ - **A pasted animation keeps playing in the UI, and the message is left alone.**
130
+ The browser already uploads an animated GIF/WebP byte-for-byte and labelled as
131
+ such; the Host flattens it to a single
132
+ frame during prompt admission and keeps no copy of the source. The plugin wraps
133
+ `ctx.attachments.admitPromptContent` to capture the frames and the original file
134
+ at that moment, and `ctx.attachments.readImage` so the UI is served the animation
135
+ itself (the browser animates whatever bytes that one read returns). What you send
136
+ is what you send: the chat message is unchanged.
137
+ - **The model now receives a labelled sheet of the frames.** The delivery step is
138
+ `ctx.attachments.readImageRequest(ref, target, signal)`, the method each adapter
139
+ calls to build its own copy of one image. It answers with exactly one image, so
140
+ the frames are composed into a grid chosen to be as square as the sheet budget
141
+ allows — every tile at its native size, with the frame index, duration and start
142
+ time printed under each, and the animation's own facts in a wrapped header. The
143
+ sheet is built once per animation and cached.
144
+ - Rewriting the request from an `llm/stream` listener is **not** used and would not
145
+ work: a loop-built request arrives deep-frozen ("listeners read it, never rewrite
146
+ it"), so that earlier attempt silently did nothing.
147
+ - Captured frames and the original are persisted per `$DSH_HOME` in
148
+ `read-animation/v1/pasted.jsonl`, keyed by the message's attachment id, so a
149
+ session resumed in a new process still displays — and still delivers — an
150
+ animation pasted before the restart.
151
+ - A frame's index and start time ride in its attachment display name
152
+ (`x.gif#frame16@1960ms`), which the harness renders beside the picture once the
153
+ frames are delivered — so the timing never has to be repeated as message text.
154
+ (An earlier version of this feature put a header plus one caption per frame into
155
+ the user's own message; consecutive text parts are concatenated by the UI with
156
+ no separator, so a one-image message became an unreadable wall.)
157
+ - `read_animation` tool: decodes an animated GIF or animated WebP, samples frames
158
+ across the timeline (or by explicit index / instant), commits each frame to the
159
+ attachment store, and returns a header plus one captioned image per frame.
160
+ - Refuses animated PNG by name instead of silently returning its first frame,
161
+ which is what the host image library does with one.
162
+ - Refuses static images and points at `read_image`; answers a single-frame GIF or
163
+ WebP with a note that it is not animated.
164
+ - Discloses decode-time substitutions in the model-facing text: zero frame
165
+ delays treated as 100 ms, missing delays, transparency flattened onto white.
166
+ - The package imports no `@deepseek-ai/*` module at runtime, so it loads both as
167
+ an installed package and as a `link:`ed checkout. The compiled schemas ship as
168
+ literals beside their authoring specs, and the integration harness keeps the
169
+ two in step.
@@ -0,0 +1,100 @@
1
+ # Compatibility
2
+
3
+ ## Supported range
4
+
5
+ **DSH 0.1.3-alpha.2 and later.** Upload compatibility rechecked on
6
+ 2026-11-08 against DSH 0.2.2-rc.1 and 0.1.7-alpha.2.
7
+
8
+ ## What was measured
9
+
10
+ ### The integration suite, actually run
11
+
12
+ `test/integration/harness.mjs` boots the real `dsh-fs-local`,
13
+ `dsh-attachment-local`, `dsh-system-prompt` and `dsh-tools` plugins in-process,
14
+ registers the tool, and exercises the paste path through a real `llm.stream`
15
+ dispatch. Pointed at four versions spanning the range:
16
+
17
+ | DSH | checks |
18
+ | --- | --- |
19
+ | `0.1.3-alpha.2` — the oldest release with the full API | 38/38 |
20
+ | `0.1.7-alpha.2` — the version this was developed against | 38/38 |
21
+ | `0.2.0-rc.2` — `latest` on that date | 38/38 |
22
+ | `0.2.1-alpha.3` — `alpha` on that date, newest | 38/38 |
23
+
24
+ The table above records the 2026-10-14 baseline. The upload regression checks
25
+ added on 2026-11-08 produced the following results:
26
+
27
+ | DSH | checks |
28
+ | --- | --- |
29
+ | `0.1.7-alpha.2` — encoded-prompt and raw image-storage paths | 40/40 |
30
+ | `0.2.2-rc.1` — includes the raw upload service and stored-reference admission | 41/41 |
31
+
32
+ DSH 0.2.2 normalizes uploaded images before prompt admission. Capture now wraps
33
+ `saveImages`, which receives raw bytes for both upload and encoded-prompt paths.
34
+ The regressions check GIF and animated WebP display, frame sheets, and reads of
35
+ normalized-copy paths. The 0.2.2 case also calls the real
36
+ `FileUploads.uploadImageStream` and resolves its receipt into a stored image
37
+ reference. The test harness mounts the native tool executor on hosts that expose
38
+ that separate service.
39
+
40
+ ```sh
41
+ DSH_HARNESS_MODULES=/path/to/dsh/node_modules node test/integration/harness.mjs
42
+ ```
43
+
44
+ ### The API surface, read from every published release
45
+
46
+ `scripts/check-dsh-compat.py` downloads the type declarations of
47
+ `@deepseek-ai/dsh-attachment` and `@deepseek-ai/dsh-attachment-local` for every
48
+ published DSH version and reports which of the members this plugin calls are
49
+ present. DSH publishes its whole monorepo at one version, so `dsh@X` pins
50
+ `dsh-attachment-local@X`.
51
+
52
+ ```sh
53
+ python scripts/check-dsh-compat.py
54
+ ```
55
+
56
+ The result over all 32 published releases: every member is present from
57
+ **0.1.3-alpha.2** onward and absent before it. The members, and the release each
58
+ first appears in:
59
+
60
+ | member | since |
61
+ | --- | --- |
62
+ | `saveImages` | `0.1.0-rc.7` |
63
+ | `readImageRequest` | `0.1.2-alpha.3` |
64
+ | `readFileStream` | `0.1.2-alpha.3` |
65
+ | `saveFile` | `0.1.3-alpha.2` |
66
+ | `fileHostPath` | `0.1.3-alpha.2` — optional; the plugin works without it |
67
+
68
+ So the floor is set by `saveFile`, which is what keeps the original animation's
69
+ bytes for the sheet, and by `fileHostPath`. Below `0.1.3-alpha.2` the plugin still
70
+ mounts and still reads animations from files; pasted animations degrade to a
71
+ single frame, and it says which member is missing and when that member arrived.
72
+
73
+ ## What is not claimed
74
+
75
+ - **Four of the seventeen releases in the range were executed.** Every release in
76
+ the range was checked to expose the full member list, but a release whose types
77
+ are complete can still behave differently. The four points span floor to ceiling.
78
+ - **The upper bound is a date, not a guarantee.** This plugin wraps
79
+ `saveImages`, `readImage`, and `readImageRequest` — internal service
80
+ methods. A DSH release that changes their signatures or the shape of a request
81
+ target will break it, and nothing here can detect that in advance.
82
+ - **The `engines.dsh` field is documentation.** npm does not enforce a
83
+ non-standard `engines` key, and DSH does not read it. It records the measured
84
+ floor; it does not stop an install.
85
+
86
+ ## Re-measuring after a DSH release
87
+
88
+ 1. `python scripts/check-dsh-compat.py` — does the new release still expose
89
+ everything?
90
+ 2. Install it somewhere and run the suite against it:
91
+
92
+ ```sh
93
+ mkdir /tmp/dsh-probe && cd /tmp/dsh-probe
94
+ npm init -y && npm i @deepseek-ai/dsh@<version>
95
+ cd <this repo>
96
+ DSH_HARNESS_MODULES=/tmp/dsh-probe/node_modules node test/integration/harness.mjs
97
+ ```
98
+
99
+ 3. If it fails, the check that fails names the method. The messages raised at mount
100
+ and from the tool also name the missing member and the release it arrived in.
package/README.md CHANGED
@@ -52,13 +52,13 @@ the chunk stream and refuses it by name instead of returning frame 0.
52
52
  paste handler hands the clipboard's own `File` straight to the upload path, and
53
53
  that path encodes `image/gif` and `image/webp` unchanged (`imageMediaType()`
54
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.
55
+ **The bytes reach the Host whole.** The Host normalizes them through
56
+ `attachments.saveImages`. In DSH 0.2.2 this happens during upload, so prompt
57
+ admission receives an already-stored image reference.
58
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**:
59
+ The plugin wraps `saveImages`, captures the animation from its raw inputs, and
60
+ stores the original and decoded frames against the returned image reference.
61
+ This covers raw uploads and older encoded-prompt admission:
62
62
 
63
63
  ```
64
64
  you paste 1 animation
@@ -143,6 +143,9 @@ the gaps are gaps.
143
143
 
144
144
  ## Install
145
145
 
146
+ Needs **DSH 0.1.3-alpha.2 or later** — see [COMPATIBILITY.md](COMPATIBILITY.md) for
147
+ the measured range, how it was measured, and what is not claimed.
148
+
146
149
  ```sh
147
150
  dsh plugin --profile web add dsh-read-animation
148
151
  ```
@@ -239,8 +242,8 @@ tool refuses to run when the resolved model route does not declare image input,
239
242
  for the same reason `read_image` does.
240
243
 
241
244
  **A pasted animation** is handled in three steps, none of which touch your
242
- message: **capture** at admission (`ctx.attachments.admitPromptContent` is the last
243
- moment the original bytes exist), **display** at `ctx.attachments.readImage` (the
245
+ message: **capture** at image storage (`ctx.attachments.saveImages` receives the
246
+ original bytes), **display** at `ctx.attachments.readImage` (the
244
247
  one read the UI makes, served the original so it plays), and **delivery** at
245
248
  `ctx.attachments.readImageRequest` (the model's copy, composed into one labelled
246
249
  sheet). All three are **monkey-patches of core service methods**, so they do
package/README.zh.md CHANGED
@@ -47,10 +47,11 @@ APNG 得的是同一种病,而且更隐蔽:libvips 8.18.7 根本不认 `acTL
47
47
  **入口一 · 贴进聊天框。** 浏览器那段路本身是干净的——粘贴处理器把 `clipboardData` 里的原始
48
48
  `File` 直接交出去,上传路径对 `image/gif` / `image/webp` 原样编码(`imageMediaType()` 明确
49
49
  接受这两种并原样返回),没有 canvas、没有 `drawImage`、没有 `toBlob`。**字节完好地到达宿主。**
50
- 丢掉它的是宿主自己:`admitPromptContent` 把每张图都过一遍「单帧」归一化,源字节不保留。
50
+ 宿主通过 `attachments.saveImages` 将图片归一化。DSH 0.2.2 在上传阶段就完成这一步,
51
+ 之后消息接收入口拿到的是已存储的图片引用。
51
52
 
52
- `admitPromptContent` 是**原件还存在的最后一刻**,所以本插件在这里把它解码、把帧存下来——
53
- 然后**把原样的结果还回去**:
53
+ 本插件在 `saveImages` 保存图片时,用仍可访问的原始字节解码动画,并将原件和帧关联到
54
+ 返回的图片引用。这同时覆盖原始文件上传和旧版编码图片入口:
54
55
 
55
56
  ```
56
57
  你贴 1 张动图
@@ -201,8 +202,8 @@ PNG,所以 harness 的归一化管线对它无事可做。
201
202
  `read_image` 完全一致。当解析出的模型路由未声明图片输入时,工具会拒绝执行——理由和
202
203
  `read_image` 相同。
203
204
 
204
- **贴进聊天框的动图**分三步,都不碰你的消息:**捕获**在准入层
205
- (`ctx.attachments.admitPromptContent` 是原件还在的最后一刻),**显示**在
205
+ **贴进聊天框的动图**分三步,都不碰你的消息:**捕获**在图片存储层
206
+ (`ctx.attachments.saveImages` 仍能访问上传原件),**显示**在
206
207
  `ctx.attachments.readImage`(界面取图的那一次读,返回原件所以会动),**交付**在
207
208
  `ctx.attachments.readImageRequest`(模型那份,拼成一张带标注的胶片条)。三步都是
208
209
  **猴补核心服务的方法**,所以写得很保守——
@@ -0,0 +1,32 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="Four frames of one animation, with the subject growing across them">
2
+ <!--
3
+ The plugin's whole idea in one mark: one animation shown as a grid of frames
4
+ over time, with the subject changing across them.
5
+
6
+ Drawn for the size it is actually rendered at, roughly 40px on a bundle card:
7
+ four bold shapes, no gradients, no text, nothing that turns to mush. The
8
+ background is a mid-tone tile so the mark reads on both a light and a dark
9
+ card, and the frames inside carry the contrast.
10
+ -->
11
+ <rect width="96" height="96" rx="21" fill="#26333F"/>
12
+ <g fill="#EDF2F8">
13
+ <rect x="10" y="10" width="36" height="36" rx="7"/>
14
+ <rect x="50" y="10" width="36" height="36" rx="7"/>
15
+ <rect x="10" y="50" width="36" height="36" rx="7"/>
16
+ <rect x="50" y="50" width="36" height="36" rx="7"/>
17
+ </g>
18
+ <!--
19
+ The subject grows across the four moments. Size is a property the eye reads
20
+ even when it scans a grid out of order, which a position change is not: in a
21
+ 2x2 layout, reading order jumps from the top right back to the bottom left, so
22
+ four dots each moving inside their own frame read as four unrelated things.
23
+ -->
24
+ <g fill="#26333F">
25
+ <circle cx="28" cy="28" r="5"/>
26
+ <circle cx="68" cy="28" r="7"/>
27
+ <circle cx="28" cy="68" r="9"/>
28
+ </g>
29
+ <!-- The last sampled moment is the one the sheet calls out, so it is the one
30
+ shape in the mark that is not greyscale. -->
31
+ <circle cx="68" cy="68" r="11" fill="#C9761B"/>
32
+ </svg>
package/lib/admission.js CHANGED
@@ -6,16 +6,15 @@
6
6
  * A pasted animation survives the browser intact: the chat input hands the
7
7
  * clipboard's own `File` to the upload path, and that path encodes `image/gif`
8
8
  * and `image/webp` unchanged — no canvas, no re-encode. The bytes arrive at the
9
- * Host whole. The Host then destroys them: `admitPromptContent` runs every image
10
- * through the attachment normalizer, which is single-frame by construction, and
11
- * the source is not kept. By the time any tool can see the attachment it is one
12
- * frame, so `read_animation` cannot help.
9
+ * Host whole. The attachment normalizer keeps only one frame and discards the
10
+ * source. DSH 0.2.2 runs it during raw upload, before prompt admission receives
11
+ * an already-stored image reference.
13
12
  *
14
- * `admitPromptContent` is the last moment the original bytes exist. This module
15
- * wraps it to decode the animation and store the frames — and then returns the
16
- * original result **unchanged**, so the message a person wrote stays exactly what
17
- * they wrote. `./display.js` serves the animation itself back to the UI, and
18
- * `./model.js` turns the frames into the model's own copy of that image.
13
+ * Both raw uploads and legacy encoded prompt admission call `saveImages`. This
14
+ * module captures its raw inputs against the returned references, before the
15
+ * caller can discard those bytes. The returned references remain unchanged.
16
+ * `./display.js` serves the animation itself back to the UI, and `./model.js`
17
+ * turns the frames into the model's own copy of that image.
19
18
  *
20
19
  * ### Why it is written defensively
21
20
  *
@@ -39,8 +38,8 @@ const ANIMATED_MEDIA_TYPES = new Set(['image/gif', 'image/webp'])
39
38
  const SUPPORTED_FORMATS = new Set(['gif', 'webp'])
40
39
 
41
40
  /**
42
- * Wrap the attachment service's prompt admission so a pasted animation's frames
43
- * are captured before the source bytes are discarded.
41
+ * Wrap image storage so uploaded and pasted animations are captured while their
42
+ * source bytes are still available.
44
43
  *
45
44
  * @param ctx - the plugin context; needs the mounted `attachments` service.
46
45
  * @param settings - the resolved plugin configuration.
@@ -53,27 +52,27 @@ export function installAdmissionCapture(ctx, settings, pasted) {
53
52
  warn(ctx, 'no attachment service is mounted; a pasted animation stays a single frame')
54
53
  return false
55
54
  }
56
- if (typeof attachments.admitPromptContent !== 'function') {
57
- warn(ctx, 'attachments.admitPromptContent is not a function; a pasted animation stays a single frame')
55
+ if (typeof attachments.saveImages !== 'function') {
56
+ warn(ctx, 'attachments.saveImages is not a function; a pasted animation stays a single frame')
58
57
  return false
59
58
  }
60
59
 
61
- const patch = wrapServiceMethod(attachments, 'admitPromptContent', (base) =>
62
- async function admitPromptContentCapturingAnimations(content) {
63
- // The caller's result is never modified. Capture is best-effort and runs on
64
- // the side; if it fails, the message is still exactly what was sent.
65
- const admitted = await base(content)
60
+ const patch = wrapServiceMethod(attachments, 'saveImages', (base) =>
61
+ async function saveImagesCapturingAnimations(inputs) {
62
+ // Capture only after the host accepts the batch. Keep its references even
63
+ // if animation capture fails.
64
+ const refs = await base(inputs)
66
65
  try {
67
- await capture(ctx, settings, pasted, content, admitted)
66
+ await capture(ctx, settings, pasted, inputs, refs)
68
67
  } catch (error) {
69
68
  warn(ctx, `could not capture a pasted animation: ${describe(error)}`)
70
69
  }
71
- return admitted
70
+ return refs
72
71
  },
73
72
  )
74
73
 
75
74
  if (!patch.installed) {
76
- warn(ctx, 'the admission wrapper did not take effect; a pasted animation stays a single frame')
75
+ warn(ctx, 'the image-storage wrapper did not take effect; a pasted animation stays a single frame')
77
76
  return false
78
77
  }
79
78
 
@@ -82,37 +81,36 @@ export function installAdmissionCapture(ctx, settings, pasted) {
82
81
  }
83
82
 
84
83
  /**
85
- * Decode each claimed image and record its frames against the admitted reference.
84
+ * Decode each claimed image and record its frames against the stored reference.
86
85
  *
87
86
  * @param ctx - the plugin context.
88
87
  * @param settings - the resolved plugin configuration.
89
88
  * @param pasted - the store to record into.
90
- * @param content - the parts as submitted, carrying the original bytes.
91
- * @param admitted - the parts the service returned, carrying the references.
89
+ * @param inputs - image storage inputs carrying the original bytes.
90
+ * @param refs - stored references returned in input order.
92
91
  */
93
- async function capture(ctx, settings, pasted, content, admitted) {
94
- if (!Array.isArray(content) || !Array.isArray(admitted)) return
92
+ async function capture(ctx, settings, pasted, inputs, refs) {
93
+ if (!Array.isArray(inputs) || !Array.isArray(refs)) return
95
94
 
96
95
  const claimed = []
97
- content.forEach((part, index) => {
98
- if (part?.type === 'image' && ANIMATED_MEDIA_TYPES.has(part.mediaType)) claimed.push(index)
96
+ inputs.forEach((input, index) => {
97
+ if (ANIMATED_MEDIA_TYPES.has(input?.mediaType)) claimed.push(index)
99
98
  })
100
99
  if (claimed.length === 0) return
101
100
 
102
101
  const limits = ctx.get('attachments')?.imageLimits
103
- const stillImages = content.filter((part) => part?.type === 'image').length - claimed.length
102
+ const stillImages = inputs.length - claimed.length
104
103
  const allowance = Math.max(0, (limits?.maxImagesPerMessage ?? 20) - stillImages)
105
104
  const perAnimation = Math.min(settings.maxFrames, Math.floor(allowance / claimed.length))
106
105
  if (perAnimation < 2) return
107
106
 
108
107
  const sharp = loadSharp()
109
108
  for (const index of claimed) {
110
- const target = admitted[index]
111
- const attachmentId = target?.type === 'image' ? target.attachment?.attachmentId : undefined
109
+ const attachmentId = refs[index]?.attachmentId
112
110
  if (attachmentId === undefined) continue
113
111
 
114
- const part = content[index]
115
- const bytes = Buffer.from(part.data, 'base64')
112
+ const part = inputs[index]
113
+ const bytes = Buffer.from(part.data)
116
114
  const animation = await probeAnimation(sharp, bytes, { maxPixels: limits?.maxImagePixels })
117
115
  if (!SUPPORTED_FORMATS.has(animation.format) || animation.frameCount < 2) continue
118
116
 
@@ -124,7 +122,7 @@ async function capture(ctx, settings, pasted, content, admitted) {
124
122
  plan.plan,
125
123
  { maxEdge: settings.maxEdge, background: settings.background, maxPixels: limits?.maxImagePixels },
126
124
  )
127
- const refs = await ctx.attachments.saveImages(
125
+ const frameRefs = await ctx.attachments.saveImages(
128
126
  rendered.map((frame) => ({
129
127
  data: frame.data,
130
128
  mediaType: 'image/png',
@@ -144,7 +142,7 @@ async function capture(ctx, settings, pasted, content, admitted) {
144
142
  })
145
143
 
146
144
  pasted.remember(attachmentId, {
147
- frames: refs.map((ref) => ({
145
+ frames: frameRefs.map((ref) => ({
148
146
  attachmentId: ref.attachmentId,
149
147
  mediaType: ref.mediaType,
150
148
  bytes: ref.bytes,
package/lib/index.js CHANGED
@@ -39,6 +39,7 @@ import { createPastedStore } from './pasted.js'
39
39
  import { installOriginalDisplay } from './display.js'
40
40
  import { installModelFrames } from './model.js'
41
41
  import { readAnimation } from './read.js'
42
+ import { missingMethods, warn } from './patch.js'
42
43
  import { OUTPUT_SCHEMA, PARAMETERS, TOOL_NAME, describeTool, validateArguments } from './tool.js'
43
44
 
44
45
  /** Cordis plugin name; matches the `id` of this bundle's `cordis.patch.yml` row. */
@@ -62,10 +63,22 @@ const DEFAULT_MAX_INPUT_BYTES = 64 * 1024 * 1024
62
63
  export function apply(ctx, config = {}) {
63
64
  const settings = resolveConfig(config)
64
65
  ctx.inject(['attachments'], (imageCtx) => {
66
+ // Say what is missing once, in full, before any installer runs. Partial
67
+ // mounting is deliberate — the tool still works on a host with `saveImages`
68
+ // but no `readImageRequest` — so this is a report, not a refusal.
69
+ const gaps = hostGaps(imageCtx.get('attachments'))
70
+ if (gaps.length > 0) {
71
+ warn(
72
+ ctx,
73
+ `this DSH build is missing ${gaps.length} thing${gaps.length === 1 ? '' : 's'} read_animation needs: ` +
74
+ `${gaps.join(', ')}. Supported from DSH ${SUPPORTED_FROM}; parts of this plugin will be inert.`,
75
+ )
76
+ }
77
+
65
78
  let pasted
66
79
  if (settings.expandPastedAnimations) {
67
- // Capture at admission (the last moment the animation's bytes exist) and
68
- // hand the frames to the model at request time. The message is not touched.
80
+ // Capture raw image storage inputs, then hand the frames to the model at
81
+ // request time. This covers uploads normalized before prompt admission.
69
82
  pasted = createPastedStore(imageCtx)
70
83
  installAdmissionCapture(imageCtx, settings, pasted)
71
84
  const display = installOriginalDisplay(imageCtx, pasted)
@@ -105,6 +118,47 @@ function resolveConfig(config) {
105
118
  return settings
106
119
  }
107
120
 
121
+ /**
122
+ * Attachment-service members this plugin calls, and the DSH version each arrived in.
123
+ *
124
+ * The floor is measured, not guessed: reading the published type declarations of
125
+ * every release shows `0.1.3-alpha.2` as the first version carrying all of these,
126
+ * and 0.1.2-rc.1 and earlier are missing `saveFile`,
127
+ * `readImageRequest`, `readFileStream` and `fileHostPath` in various combinations.
128
+ * `fileHostPath` is deliberately absent from this list: the plugin works without
129
+ * it, and says less on the sheet when it is missing.
130
+ */
131
+ const REQUIRED = [
132
+ { name: 'saveFile', since: '0.1.3-alpha.2' },
133
+ { name: 'saveImages', since: '0.1.0-rc.7' },
134
+ { name: 'readImage', since: '0.0.1-rc.1' },
135
+ { name: 'readImageRequest', since: '0.1.2-alpha.3' },
136
+ { name: 'readFileStream', since: '0.1.2-alpha.3' },
137
+ ]
138
+
139
+ /** The supported floor, quoted in the message an unsupported host gets. */
140
+ const SUPPORTED_FROM = '0.1.3-alpha.2'
141
+
142
+ /**
143
+ * What this host does not provide, in words the person reading the log can act on.
144
+ *
145
+ * Checked once at mount. Each installer also guards the method it wraps, but a
146
+ * guard only covers the method being replaced: a host with `readImage` and no
147
+ * `saveImages` would mount cleanly and then throw a `TypeError` from inside the
148
+ * tool, several layers below anything that names the cause.
149
+ */
150
+ function hostGaps(attachments) {
151
+ if (attachments === undefined) return ['no attachment service is mounted']
152
+ const gaps = missingMethods(attachments, REQUIRED.map((entry) => entry.name)).map(
153
+ (name) => `attachments.${name}() (added in DSH ${REQUIRED.find((entry) => entry.name === name).since})`,
154
+ )
155
+ if (attachments.imageLimits === undefined) gaps.push('attachments.imageLimits')
156
+ else if (typeof attachments.imageLimits.maxImagePixels !== 'number') {
157
+ gaps.push('attachments.imageLimits.maxImagePixels')
158
+ }
159
+ return gaps
160
+ }
161
+
108
162
  /** Build and register the `read_animation` tool definition. */
109
163
  function registerTool(ctx, settings, pasted) {
110
164
  ctx.tools.register({
package/lib/pacing.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Where an animation's change actually is, and which frames to show because of it.
3
+ *
4
+ * ### Why this exists
5
+ *
6
+ * Sampling a fixed number of frames at equal intervals answers "what does this
7
+ * animation look like", which is not the same question as "how does it go". The
8
+ * budget is spread over *time*, while an animation's content lives in *change*: a
9
+ * long hold at either end gets the same share as the moment something happens.
10
+ *
11
+ * Measured on a real 56-frame clip: even sampling spent three of its eight tiles
12
+ * on the opening where the frame-to-frame change is 1-6 (of 255), and never
13
+ * sampled the single largest change in the clip (frame 53, change 31.4). Padded to
14
+ * 136 frames with held ends — what a trimmed screen recording looks like — five of
15
+ * the eight tiles were pixel-identical to their neighbour.
16
+ *
17
+ * ### What it deliberately does not do
18
+ *
19
+ * It does not replace coverage with content. The literature on keyframe selection
20
+ * is split: content-aware selection wins on information-dense questions and
21
+ * uniform sampling keeps an edge on temporal continuity, which is exactly what a
22
+ * story is made of. So the equal-interval skeleton is kept, and a tile is moved
23
+ * **only when the stretch around it is measured to be still** — meaning that tile
24
+ * and its neighbour show the same picture and nothing is lost by moving it. When
25
+ * nothing qualifies, this returns the even picks unchanged.
26
+ *
27
+ * The module is pure: the caller decodes the frames (including the in-between
28
+ * probes) and this decides. That keeps the decision testable without an image
29
+ * library, and keeps the cost of probing a caller concern.
30
+ *
31
+ * @module dsh-read-animation/pacing
32
+ */
33
+
34
+ /**
35
+ * Mean absolute pixel difference between two frames, on the 0-255 scale.
36
+ *
37
+ * Measured on real material: held frames score 0.0, a slow intro scores 1-6, and
38
+ * action scores 12-31. The two thresholds below come from that spread rather than
39
+ * from taste.
40
+ */
41
+ export function changeBetween(a, b) {
42
+ const length = Math.min(a.length, b.length)
43
+ if (length === 0) return 0
44
+ let total = 0
45
+ for (let at = 0; at < length; at += 1) total += Math.abs(a[at] - b[at])
46
+ return total / length
47
+ }
48
+
49
+ /** Below this, two frames are the same picture for any practical purpose. */
50
+ export const STILL = 2
51
+
52
+ /** Above this, something is happening between the two frames. */
53
+ export const BUSY = 8
54
+
55
+ /**
56
+ * Describe the change in every gap between consecutive chosen frames.
57
+ *
58
+ * A gap is probed once, in the middle, and the two halves are compared: a gap
59
+ * whose middle differs from both ends is a gap where something moved.
60
+ *
61
+ * @param chosen - the frame indices that will be shown, in order.
62
+ * @param decoded - `Map<index, Uint8Array>` of greyscale pixels, holding at least
63
+ * the chosen frames and whatever {@link probePlan} asked for in between.
64
+ * @param frameCount - frames in the whole animation.
65
+ * @returns per-gap facts, plus which gaps are still and which are busy.
66
+ */
67
+ export function analyzePacing(chosen, decoded, frameCount) {
68
+ const at = (index) => decoded.get(index)
69
+
70
+ const gaps = []
71
+ for (let slot = 1; slot < chosen.length; slot += 1) {
72
+ const from = chosen[slot - 1]
73
+ const to = chosen[slot]
74
+ const middle = Math.round((from + to) / 2)
75
+ const left = at(from)
76
+ const right = at(to)
77
+ if (left === undefined || right === undefined) continue
78
+
79
+ const between = middle > from && middle < to ? at(middle) : undefined
80
+ const change =
81
+ between === undefined
82
+ ? changeBetween(left, right)
83
+ : Math.max(changeBetween(left, between), changeBetween(between, right))
84
+
85
+ gaps.push({ from, to, frames: to - from, change, still: change < STILL, busy: change > BUSY })
86
+ }
87
+
88
+ const busiest = gaps.reduce((best, gap) => (best === undefined || gap.change > best.change ? gap : best), undefined)
89
+
90
+ // How much of each end is still, which is what tells a reader where the story
91
+ // is rather than where the file happens to start.
92
+ const firstMoving = gaps.findIndex((gap) => !gap.still)
93
+ const lastMoving = gaps.length - 1 - [...gaps].reverse().findIndex((gap) => !gap.still)
94
+
95
+ return {
96
+ gaps,
97
+ busiest,
98
+ stillGaps: gaps.filter((gap) => gap.still),
99
+ busyGaps: gaps.filter((gap) => gap.busy),
100
+ stillUntil: firstMoving > 0 ? gaps[firstMoving].from : undefined,
101
+ stillFrom: lastMoving >= 0 && lastMoving < gaps.length - 1 ? gaps[lastMoving].to : undefined,
102
+ frameCount,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The frames to show, under the chosen strategy.
108
+ *
109
+ * `content` removes a tile only when **both** gaps beside it were measured still:
110
+ * removing it then merges two still stretches into one that is still by the same
111
+ * measurement, so nothing unmeasured is ever created. The freed tiles go into the
112
+ * busiest gaps, at the probe that measured them.
113
+ *
114
+ * The stricter rule is not caution for its own sake. The first version removed a
115
+ * tile when *either* neighbour was still, and on a padded clip that produced a
116
+ * 49-frame gap that had never been probed — a coverage hole presented as a
117
+ * saving. The constraint that matters is not "no long gaps" but "no *unmeasured*
118
+ * gaps": a sixty-frame stretch that was measured still is a correctly compressed
119
+ * hold, and losing it costs nothing.
120
+ *
121
+ * @param frameCount - frames in the animation.
122
+ * @param count - how many frames to choose.
123
+ * @param pacing - the report from {@link analyzePacing}, or undefined when the
124
+ * caller did not probe (in which case only `even` is possible).
125
+ * @param strategy - `'even'` keeps equal intervals; `'content'` moves only the
126
+ * tiles whose surroundings are measured still.
127
+ * @returns `{ indices, moved }`, where `moved` explains each relocation.
128
+ */
129
+ export function chooseFrames(frameCount, count, pacing, strategy) {
130
+ const even = evenIndices(frameCount, count)
131
+ if (strategy !== 'content' || pacing === undefined || even.length < 3) {
132
+ return { indices: even, moved: [] }
133
+ }
134
+
135
+ const measured = new Map(pacing.gaps.map((gap) => [`${gap.from}:${gap.to}`, gap]))
136
+
137
+ /** The status of the gap between two kept frames, or undefined if nobody measured it. */
138
+ const gapBetween = (from, to) => {
139
+ const exact = measured.get(`${from}:${to}`)
140
+ if (exact !== undefined) return exact
141
+ const between = pacing.gaps.filter((gap) => gap.from >= from && gap.to <= to)
142
+ if (between.length === 0 || between.some((gap) => !gap.still)) return undefined
143
+ return { from, to, frames: to - from, change: Math.max(...between.map((gap) => gap.change)), still: true, busy: false }
144
+ }
145
+
146
+ // Remove the tiles that sit inside a still stretch, at both ends of it. The
147
+ // first and last frames are never removed: they are the animation's state.
148
+ const kept = [...even]
149
+ const freed = []
150
+ for (let at = even.length - 2; at >= 1; at -= 1) {
151
+ const before = measured.get(`${even[at - 1]}:${even[at]}`)
152
+ const after = measured.get(`${even[at]}:${even[at + 1]}`)
153
+ if (before?.still === true && after?.still === true) {
154
+ freed.push({ index: even[at], stillChange: Math.max(before.change, after.change) })
155
+ kept.splice(kept.indexOf(even[at]), 1)
156
+ }
157
+ }
158
+
159
+ const moved = []
160
+ for (const freedTile of freed) {
161
+ // The busiest gap that still has room for a frame in the middle of it.
162
+ const target = kept
163
+ .map((from, at) => (at + 1 < kept.length ? gapBetween(from, kept[at + 1]) : undefined))
164
+ .map((gap, at) => ({ gap, at }))
165
+ .filter(({ gap }) => gap?.busy === true && gap.frames > 2)
166
+ .sort((a, b) => b.gap.change - a.gap.change)[0]
167
+ if (target === undefined) break
168
+
169
+ const insert = Math.round((target.gap.from + target.gap.to) / 2)
170
+ const crowded = kept.some((index) => Math.abs(index - insert) < 2)
171
+ if (crowded || insert <= 0 || insert >= frameCount - 1) break
172
+
173
+ kept.splice(kept.findIndex((index) => index > insert), 0, insert)
174
+ moved.push({ from: freedTile.index, to: insert, gapChange: target.gap.change, stillChange: freedTile.stillChange })
175
+ }
176
+
177
+ return { indices: kept, moved }
178
+ }
179
+
180
+ /**
181
+ * Equal intervals, first and last included.
182
+ *
183
+ * This is the sampler the plugin has always used, kept as the default and as the
184
+ * skeleton the content strategy starts from.
185
+ */
186
+ export function evenIndices(frameCount, count) {
187
+ if (frameCount <= 1 || count <= 1) return [0]
188
+ if (count >= frameCount) return [...Array(frameCount).keys()]
189
+ const picks = []
190
+ for (let slot = 0; slot < count; slot += 1) picks.push(Math.round((slot * (frameCount - 1)) / (count - 1)))
191
+ return [...new Set(picks)]
192
+ }
193
+
194
+ /**
195
+ * Where to probe for the change in each gap, without decoding every frame.
196
+ *
197
+ * One probe per gap, plus the two ends of the animation so the "still" stretches
198
+ * can be described. That is `count + 1` extra decodes regardless of how long the
199
+ * animation is, against `frameCount` for a full change profile — which is what
200
+ * makes this affordable on a 300-frame clip.
201
+ *
202
+ * @param indices - the chosen frames, in order.
203
+ * @param frameCount - frames in the animation.
204
+ * @returns `{ samples, probes }` as index lists, deduplicated and in order.
205
+ */
206
+ export function probePlan(indices, frameCount) {
207
+ const wanted = new Set(indices)
208
+ for (let at = 1; at < indices.length; at += 1) {
209
+ const middle = Math.round((indices[at - 1] + indices[at]) / 2)
210
+ if (middle > indices[at - 1] && middle < indices[at]) wanted.add(middle)
211
+ }
212
+ // The frames just before the first sample and after the last, so an opening or
213
+ // closing hold is visible rather than inferred.
214
+ if (indices[0] > 0) wanted.add(indices[0] - 1)
215
+ if (indices[indices.length - 1] < frameCount - 1) wanted.add(indices[indices.length - 1] + 1)
216
+ return [...wanted].filter((index) => index >= 0 && index < frameCount).sort((a, b) => a - b)
217
+ }
package/lib/patch.js CHANGED
@@ -52,6 +52,24 @@ export function isWrapped(value) {
52
52
  return typeof value === 'function' && value[LEVEL] !== undefined
53
53
  }
54
54
 
55
+ /**
56
+ * Which of `names` the service does not implement.
57
+ *
58
+ * Checked before wrapping anything. Installing a wrapper over a method that is not
59
+ * there used to *create* the property, so the host acquired a function whose only
60
+ * behaviour was to throw a `TypeError` from deep inside a call that looked
61
+ * ordinary — and the plugin reported nothing at mount, when it knew the host did
62
+ * not have what it needed.
63
+ *
64
+ * @param service - the service object, or undefined when the service is absent.
65
+ * @param names - the members this plugin calls.
66
+ * @returns the missing names, in the order given.
67
+ */
68
+ export function missingMethods(service, names) {
69
+ if (service === undefined || service === null) return [...names]
70
+ return names.filter((name) => typeof service[name] !== 'function')
71
+ }
72
+
55
73
  /**
56
74
  * Wrap one method.
57
75
  *
package/lib/read.js CHANGED
@@ -54,6 +54,12 @@ export async function readAnimation(ctx, settings, args, exec, pasted) {
54
54
 
55
55
  const attachments = ctx.get('attachments')
56
56
  if (attachments === undefined) throw new Error('read_animation requires a mounted attachment store')
57
+ if (typeof attachments.saveImages !== 'function') {
58
+ throw new Error(
59
+ 'read_animation needs attachments.saveImages() to store the frames it decodes, and this DSH build does not ' +
60
+ 'provide it; read_animation supports DSH 0.1.3-alpha.2 and later',
61
+ )
62
+ }
57
63
  await assertImageCapableRoute(ctx, exec, args.file_path)
58
64
 
59
65
  const target = await ctx.fs.resolve(args.file_path, resolveOptions(exec))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-animation",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Let a DeepSeek Harness agent actually see an animated GIF or animated WebP: decode every frame, sample the animation across time, and hand the model a labelled sequence of real frames instead of the single first frame the harness otherwise delivers.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,13 +13,16 @@
13
13
  "files": [
14
14
  "lib",
15
15
  "scripts",
16
+ "assets",
16
17
  "cordis.patch.yml",
17
18
  "README.md",
18
19
  "README.zh.md",
19
20
  "CHANGELOG.md",
21
+ "COMPATIBILITY.md",
20
22
  "THIRD_PARTY.md",
21
23
  "LICENSE"
22
24
  ],
25
+ "icon": "assets/icon.svg",
23
26
  "license": "MIT",
24
27
  "author": "NeoHotaru (https://github.com/NeoHotaru)",
25
28
  "repository": {
@@ -35,7 +38,7 @@
35
38
  },
36
39
  "engines": {
37
40
  "node": "^22.19.0 || >=24.0.0",
38
- "dsh": ">=0.1.7-alpha.1"
41
+ "dsh": ">=0.1.3-alpha.2"
39
42
  },
40
43
  "keywords": [
41
44
  "dsh",
@@ -55,7 +58,7 @@
55
58
  }
56
59
  },
57
60
  "scripts": {
58
- "test": "node --test test/animation.test.js test/decode.test.js test/envelope.test.js test/filmstrip.test.js test/model.test.js test/pasted.test.js test/patch.test.js test/read.test.js test/shipped.test.js test/tool.test.js",
61
+ "test": "node --test test/animation.test.js test/decode.test.js test/envelope.test.js test/filmstrip.test.js test/model.test.js test/pacing.test.js test/pasted.test.js test/patch.test.js test/read.test.js test/shipped.test.js test/tool.test.js",
59
62
  "test:integration": "node test/integration/harness.mjs",
60
63
  "test:all": "npm test && npm run test:integration",
61
64
  "prepublishOnly": "npm test",
@@ -0,0 +1,121 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Which DSH versions expose every attachment-store member this plugin uses.
3
+
4
+ DSH publishes its whole monorepo at one version, so dsh@X pins
5
+ dsh-attachment-local@X. That makes the probe a straight walk over DSH versions:
6
+ download the two packages that declare the attachment service, concatenate their
7
+ type declarations, and ask which of the members this plugin calls are present.
8
+
9
+ A version whose declarations could not be fetched is reported as NO DATA, never as
10
+ a pass. The first version of this probe returned "OK" for every release because it
11
+ looked in the wrong place and treated an empty haystack as an empty list of
12
+ missing needles.
13
+ """
14
+ import io
15
+ import json
16
+ import sys
17
+ import tarfile
18
+ import urllib.request
19
+ from concurrent.futures import ThreadPoolExecutor
20
+
21
+ REG = 'https://registry.npmjs.org'
22
+ CACHE = {}
23
+
24
+ REQUIRED = [
25
+ 'saveImages',
26
+ 'saveFile',
27
+ 'readImage',
28
+ 'readImageRequest',
29
+ 'readFileStream',
30
+ 'fileHostPath',
31
+ 'imageLimits',
32
+ 'maxImagePixels',
33
+ 'root',
34
+ ]
35
+
36
+ PACKAGES = ['@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-attachment-local']
37
+
38
+
39
+ def fetch(url, binary=False):
40
+ req = urllib.request.Request(url, headers={'cache-control': 'no-cache',
41
+ 'user-agent': 'dsh-compat-probe'})
42
+ with urllib.request.urlopen(req, timeout=90) as r:
43
+ return r.read() if binary else r.read().decode('utf-8', 'replace')
44
+
45
+
46
+ def packument(name):
47
+ key = 'meta:' + name
48
+ if key not in CACHE:
49
+ CACHE[key] = json.loads(fetch('%s/%s' % (REG, name.replace('/', '%2F'))))
50
+ return CACHE[key]
51
+
52
+
53
+ def declarations(name, version):
54
+ """Every .d.ts in one published package, or None when there is nothing to read."""
55
+ key = '%s@%s' % (name, version)
56
+ if key in CACHE:
57
+ return CACHE[key]
58
+ CACHE[key] = None
59
+ try:
60
+ entry = packument(name)['versions'].get(version)
61
+ if entry is None:
62
+ return None
63
+ blob = fetch(entry['dist']['tarball'], binary=True)
64
+ text = []
65
+ with tarfile.open(fileobj=io.BytesIO(blob), mode='r:gz') as tar:
66
+ for member in tar.getmembers():
67
+ if member.name.endswith('.d.ts') and member.isfile():
68
+ text.append(tar.extractfile(member).read().decode('utf-8', 'replace'))
69
+ CACHE[key] = '\n'.join(text) if text else None
70
+ except Exception as error:
71
+ print(' ! %s: %s' % (key, error), file=sys.stderr)
72
+ return CACHE[key]
73
+
74
+
75
+ def main():
76
+ meta = packument('@deepseek-ai/dsh')
77
+ versions = sorted(meta['versions'], key=lambda v: meta['time'][v])
78
+
79
+ def probe(version):
80
+ texts = []
81
+ sources = []
82
+ for name in PACKAGES:
83
+ text = declarations(name, version)
84
+ if text:
85
+ texts.append(text)
86
+ sources.append(name.rsplit('/', 1)[-1])
87
+ if not texts:
88
+ return version, None, None
89
+ joined = '\n'.join(texts)
90
+ missing = [member for member in REQUIRED if member not in joined]
91
+ return version, missing, '+'.join(sources)
92
+
93
+ with ThreadPoolExecutor(max_workers=6) as pool:
94
+ results = list(pool.map(probe, versions))
95
+
96
+ print('%-18s %-9s %s' % ('dsh', 'read from', 'missing members'))
97
+ print('-' * 84)
98
+ state = {}
99
+ for version, missing, sources in results:
100
+ if missing is None:
101
+ label = 'NO DATA'
102
+ elif missing:
103
+ label = ', '.join(missing)
104
+ else:
105
+ label = 'OK'
106
+ state[version] = label
107
+ print('%-18s %-9s %s' % (version, sources or '-', label))
108
+
109
+ ok = [v for v in versions if state[v] == 'OK']
110
+ nodata = [v for v in versions if state[v] == 'NO DATA']
111
+ print('\nevery member present : %s' % (', '.join(ok) if ok else 'none'))
112
+ print('no data : %s' % (', '.join(nodata) if nodata else 'none'))
113
+ if ok:
114
+ print('\n=> continuous support from %s through %s' % (ok[0], ok[-1]))
115
+ first_bad = [v for v in versions[:versions.index(ok[0])]]
116
+ if first_bad:
117
+ print(' versions before it that lack something: %s' % ', '.join(first_bad))
118
+
119
+
120
+ if __name__ == '__main__':
121
+ main()
@@ -37,10 +37,14 @@ const files = join(dshHome, 'attachments', 'v1', 'files')
37
37
  *
38
38
  * Mirrors `ctx.attachments.fileHostPath`, which the plugin uses for the header's
39
39
  * last line. This script has no mounted service, so the layout is spelled out
40
- * here; the integration harness asserts the real accessor returns a real path.
40
+ * here — and the first version of it spelled the layout wrong, printing
41
+ * `files/<name>` where the store uses `files/<2>/<digest>/<name>`, which put a
42
+ * path that does not exist onto the preview. The integration harness asserts the
43
+ * real accessor returns a real file, so only this script could drift.
41
44
  */
42
45
  function keptFilePath(ref) {
43
- return join(files, ref.name)
46
+ const digest = String(ref.attachmentId).replace(/^sha256:/, '')
47
+ return join(files, digest.slice(0, 2), digest, ref.name)
44
48
  }
45
49
 
46
50
  /** Where a content-addressed attachment object lives. */