dsh-read-animation 0.0.0-stage → 0.1.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/CHANGELOG.md +124 -0
- package/LICENSE +21 -0
- package/README.md +321 -2
- package/README.zh.md +265 -0
- package/THIRD_PARTY.md +35 -0
- package/cordis.patch.yml +14 -0
- package/lib/admission.js +194 -0
- package/lib/animation.js +426 -0
- package/lib/bytes.js +31 -0
- package/lib/display.js +125 -0
- package/lib/envelope.js +110 -0
- package/lib/filmstrip.js +375 -0
- package/lib/index.js +157 -0
- package/lib/model.js +242 -0
- package/lib/pasted.js +179 -0
- package/lib/patch.js +147 -0
- package/lib/read.js +233 -0
- package/lib/sharp.js +102 -0
- package/lib/tool.js +318 -0
- package/package.json +88 -4
- package/scripts/derive-schema.mjs +44 -0
- package/scripts/preview-sheet.mjs +85 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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.
|
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,322 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-read-animation
|
|
2
2
|
|
|
3
|
-
|
|
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. Both routes are verified in a real profile:
|
|
151
|
+
the install lands, `dsh plugin add` registers the bundle, and the `sharp` native
|
|
152
|
+
binary resolves from the package's own `node_modules`.
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
dsh plugin --profile web add dsh-read-animation # npm
|
|
156
|
+
dsh plugin --profile web add github:NeoHotaru/dsh-read-animation#v0.1.0 # a tagged commit
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The git route copies the whole repository, tests and fixtures included, which is
|
|
160
|
+
why npm is the nicer install. (A brand-new package name goes through npm's staged
|
|
161
|
+
publishing on its first release, so the version you install may briefly not exist
|
|
162
|
+
yet while the registry processes it.)
|
|
163
|
+
|
|
164
|
+
Or from a checkout:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
cd dsh-read-animation
|
|
168
|
+
npm install # fetches sharp
|
|
169
|
+
dsh plugin --profile web add link:$PWD
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The same spec works in the Web sidebar's Plugins page, and in Settings the plugin
|
|
173
|
+
list is read-only. Restart the running instance afterwards: plugin rows take
|
|
174
|
+
effect on the next process, and a `link:`ed checkout is loaded once at startup.
|
|
175
|
+
|
|
176
|
+
## Usage
|
|
177
|
+
|
|
178
|
+
The model reaches for it on its own for a `.gif`, because the tool description
|
|
179
|
+
says to. You can also ask directly:
|
|
180
|
+
|
|
181
|
+
> what happens in this GIF? — `animations/loading-spinner.gif`
|
|
182
|
+
|
|
183
|
+
Arguments:
|
|
184
|
+
|
|
185
|
+
| argument | meaning |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `file_path` | the animation to read (required) |
|
|
188
|
+
| `frames` | explicit 0-based indices, in the order given — up to 16 |
|
|
189
|
+
| `at` | millisecond instants to sample instead of an even spread |
|
|
190
|
+
| `max_frames` | how many frames to sample (default 8, maximum 16) |
|
|
191
|
+
| `max_edge` | long-edge size of each returned frame in px (default 640) |
|
|
192
|
+
|
|
193
|
+
A useful loop, when the first read is not enough detail:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
read_animation { file_path: "dance.gif" } → 8 frames across the timeline
|
|
197
|
+
read_animation { file_path: "dance.gif",
|
|
198
|
+
frames: [11, 12, 13], max_edge: 1024 } → those three, full size
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Configuration
|
|
202
|
+
|
|
203
|
+
Optional, in the plugin row of your profile's `cordis.patch.yml`:
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
- id: read-animation
|
|
207
|
+
name: 'dsh-read-animation'
|
|
208
|
+
config:
|
|
209
|
+
maxFrames: 8 # default frames per call, 1..16
|
|
210
|
+
maxEdge: 640 # default long-edge cap, 64..2048
|
|
211
|
+
background: white # "white" flattens transparency, "keep" preserves it
|
|
212
|
+
maxInputBytes: 67108864 # refuse animations larger than this
|
|
213
|
+
expandPastedAnimations: true # a pasted animation keeps playing in the UI and reaches the model as a frame sheet
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## What it will not do
|
|
217
|
+
|
|
218
|
+
- **Video.** MP4/WebM need ffmpeg and a different decode path. Use
|
|
219
|
+
[`dsh-video-tools`](https://github.com/PiedPiper911/dsh-video-tools).
|
|
220
|
+
- **APNG.** Not decodable by the host image library; refused with an explanation
|
|
221
|
+
(see above).
|
|
222
|
+
- **Read a static image.** It says so and points at `read_image`. A single-frame
|
|
223
|
+
GIF or WebP is answered, with a note that it is not animated.
|
|
224
|
+
- **Guess.** When the sampler returns fewer frames than you asked for, the header
|
|
225
|
+
says which ones and why.
|
|
226
|
+
|
|
227
|
+
## How it behaves under the hood
|
|
228
|
+
|
|
229
|
+
Frames are committed through `ctx.attachments.saveImages`, the same store every
|
|
230
|
+
other harness image goes through. So frames are content-addressed (two identical
|
|
231
|
+
frames in one GIF cost one attachment; re-reading a file reuses what is already
|
|
232
|
+
stored), durable across a session replay, and subject to the deployment's own
|
|
233
|
+
image limits. Frames are resized to the target edge and re-encoded as PNG before
|
|
234
|
+
storage, so the harness's normalizer has nothing left to do to them.
|
|
235
|
+
|
|
236
|
+
Reading goes through `ctx.fs.readBytes` and emits `fs/observed`, so the sandbox
|
|
237
|
+
and the read-before-write policy apply exactly as they do to `read_image`. The
|
|
238
|
+
tool refuses to run when the resolved model route does not declare image input,
|
|
239
|
+
for the same reason `read_image` does.
|
|
240
|
+
|
|
241
|
+
**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
|
|
244
|
+
one read the UI makes, served the original so it plays), and **delivery** at
|
|
245
|
+
`ctx.attachments.readImageRequest` (the model's copy, composed into one labelled
|
|
246
|
+
sheet). All three are **monkey-patches of core service methods**, so they do
|
|
247
|
+
nothing at all the moment anything is not as expected — a missing method, a failed
|
|
248
|
+
assignment, a decode that throws, an original that cannot be read back, a batch
|
|
249
|
+
with no room. The caller always receives exactly what it would have received
|
|
250
|
+
anyway. Captured frames and the original file are recorded in
|
|
251
|
+
`$DSH_HOME/read-animation/v1/pasted.jsonl`, keyed by the attachment id the message
|
|
252
|
+
carries, so a session resumed in a new process still displays — and still expands
|
|
253
|
+
— an animation pasted before the restart. Delivery swaps references only — at that
|
|
254
|
+
layer an image is still `{type: 'image', attachment}` — and never touches bytes.
|
|
255
|
+
|
|
256
|
+
### Design notes for contributors
|
|
257
|
+
|
|
258
|
+
Two decisions are worth knowing before you change anything.
|
|
259
|
+
|
|
260
|
+
**Frames are extracted one at a time with `sharp(buf, { page: i })`, not by
|
|
261
|
+
decoding the animation as one tall strip and slicing it.** Both were measured on
|
|
262
|
+
a 600×400, 60-frame GIF: the strip path costs 54.9 MiB resident and is *slower*
|
|
263
|
+
here (228 ms for 8 frames at a 512 px target, because it resizes all 60 frames)
|
|
264
|
+
than per-frame extraction (182 ms, one frame resident at a time). The correctness
|
|
265
|
+
claim this rests on — that `{ page: i }` is byte-identical to frame `i` of the
|
|
266
|
+
composited strip — is pinned by a test.
|
|
267
|
+
|
|
268
|
+
**The package imports no `@deepseek-ai/*` at runtime.** The harness's own tools
|
|
269
|
+
build their definitions with `defineTool`, which compiles a schema spec and wraps
|
|
270
|
+
the body with argument validation. That is right for a plugin installed *as a
|
|
271
|
+
package*, where pnpm places it under `$DSH_HOME/profiles/<name>/node_modules/`
|
|
272
|
+
and `@deepseek-ai/*` resolves by walking up to the profile's hoisted tree. It is
|
|
273
|
+
wrong for a plugin *linked from a working copy*: a `link:`ed package loads
|
|
274
|
+
through its real path, so its imports resolve upward from the checkout, where no
|
|
275
|
+
`@deepseek-ai/*` exists — measured, by importing the entry with `node_modules`
|
|
276
|
+
absent (`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-tools'`).
|
|
277
|
+
`lib/tool.js` therefore ships the compiled JSON Schema as literals beside the
|
|
278
|
+
authoring spec, and the integration harness asserts the real `defineTool` still
|
|
279
|
+
derives exactly those literals from that spec. Edit the spec, run
|
|
280
|
+
`npm run derive-schema`, paste the result.
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
```sh
|
|
285
|
+
npm install
|
|
286
|
+
npm test # decoder, sampling, envelope, wrapping, store, layout, arguments
|
|
287
|
+
npm run test:integration # real dsh-fs, dsh-attachments, and dsh-tools, in-process
|
|
288
|
+
npm run fixtures # regenerate test/fixtures (needs Python 3 + Pillow)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`npm test` needs nothing but Node and the installed `sharp`, and it is the gate
|
|
292
|
+
`npm publish` runs. It also holds the two invariants that used to live only in the
|
|
293
|
+
integration harness — the no-`@deepseek-ai`-import rule and the bundle's
|
|
294
|
+
consistency with `package.json` — because that harness prints SKIP and exits 0
|
|
295
|
+
when `DSH_HARNESS_MODULES` is unset, so "the integration suite is green" could
|
|
296
|
+
otherwise mean nothing had been checked.
|
|
297
|
+
|
|
298
|
+
`npm run test:integration` boots the genuine `dsh-fs-local`,
|
|
299
|
+
`dsh-attachment-local`, `dsh-system-prompt`, and `dsh-tools` plugins in-process,
|
|
300
|
+
registers the tool, and executes it. It exists to prove the one thing no unit
|
|
301
|
+
test can reach and the whole design depends on: that a single tool result
|
|
302
|
+
carrying *several* image blocks survives the real registry and the real
|
|
303
|
+
attachment store. It also drives the pasted-animation path end to end — a real
|
|
304
|
+
`llm.stream` dispatch, a deep-frozen request, and the request image the adapter
|
|
305
|
+
receives — because three separate bugs in this plugin were invisible to
|
|
306
|
+
everything that did not go through that path. Only the `llm` service and the
|
|
307
|
+
agent object are stubbed, both because they need a live model session. Point it
|
|
308
|
+
at your harness install:
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
DSH_HARNESS_MODULES="$(npm root -g)/@deepseek-ai/dsh/node_modules" npm run test:integration
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
On a machine that has no `sharp` reachable from the checkout,
|
|
315
|
+
`DSH_READ_ANIMATION_SHARP=<path to a loadable sharp>` overrides resolution.
|
|
316
|
+
|
|
317
|
+
The fixtures are committed, so the suite runs without Python; `make-fixtures.py`
|
|
318
|
+
regenerates them and documents what each one is for.
|
|
319
|
+
|
|
320
|
+
## License
|
|
321
|
+
|
|
322
|
+
MIT. See [THIRD_PARTY.md](THIRD_PARTY.md) for sharp and libvips.
|