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 +169 -124
- package/COMPATIBILITY.md +100 -0
- package/README.md +11 -8
- package/README.zh.md +6 -5
- package/assets/icon.svg +32 -0
- package/lib/admission.js +33 -35
- package/lib/index.js +56 -2
- package/lib/pacing.js +217 -0
- package/lib/patch.js +18 -0
- package/lib/read.js +6 -0
- package/package.json +6 -3
- package/scripts/check-dsh-compat.py +121 -0
- package/scripts/preview-sheet.mjs +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,124 +1,169 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
animation
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
package/COMPATIBILITY.md
ADDED
|
@@ -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.**
|
|
56
|
-
`
|
|
57
|
-
|
|
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
|
-
`
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
243
|
-
|
|
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
|
-
|
|
50
|
+
宿主通过 `attachments.saveImages` 将图片归一化。DSH 0.2.2 在上传阶段就完成这一步,
|
|
51
|
+
之后消息接收入口拿到的是已存储的图片引用。
|
|
51
52
|
|
|
52
|
-
`
|
|
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.
|
|
205
|
+
**贴进聊天框的动图**分三步,都不碰你的消息:**捕获**在图片存储层
|
|
206
|
+
(`ctx.attachments.saveImages` 仍能访问上传原件),**显示**在
|
|
206
207
|
`ctx.attachments.readImage`(界面取图的那一次读,返回原件所以会动),**交付**在
|
|
207
208
|
`ctx.attachments.readImageRequest`(模型那份,拼成一张带标注的胶片条)。三步都是
|
|
208
209
|
**猴补核心服务的方法**,所以写得很保守——
|
package/assets/icon.svg
ADDED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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.
|
|
57
|
-
warn(ctx, 'attachments.
|
|
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, '
|
|
62
|
-
async function
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
const
|
|
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,
|
|
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
|
|
70
|
+
return refs
|
|
72
71
|
},
|
|
73
72
|
)
|
|
74
73
|
|
|
75
74
|
if (!patch.installed) {
|
|
76
|
-
warn(ctx, 'the
|
|
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
|
|
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
|
|
91
|
-
* @param
|
|
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,
|
|
94
|
-
if (!Array.isArray(
|
|
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
|
-
|
|
98
|
-
if (
|
|
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 =
|
|
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
|
|
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 =
|
|
115
|
-
const bytes = Buffer.from(part.data
|
|
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
|
|
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:
|
|
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
|
|
68
|
-
//
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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. */
|