dsh-read-animation 0.1.0 → 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 -103
- package/COMPATIBILITY.md +100 -0
- package/README.md +20 -16
- package/README.zh.md +12 -10
- package/assets/icon.svg +32 -0
- package/lib/admission.js +33 -35
- package/lib/bytes.js +31 -0
- package/lib/display.js +2 -17
- package/lib/index.js +64 -6
- package/lib/model.js +34 -6
- package/lib/pacing.js +217 -0
- package/lib/patch.js +18 -0
- package/lib/read.js +76 -5
- package/package.json +6 -3
- package/scripts/check-dsh-compat.py +121 -0
- package/scripts/preview-sheet.mjs +20 -12
package/lib/read.js
CHANGED
|
@@ -7,26 +7,59 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { planFrames, probeAnimation, renderFrames } from './animation.js'
|
|
10
|
+
import { collectBytes } from './bytes.js'
|
|
10
11
|
import { loadSharp } from './sharp.js'
|
|
11
12
|
|
|
12
13
|
/** Media types this tool accepts, independent of what the deployment accepts for storage. */
|
|
13
14
|
const SUPPORTED_FORMATS = new Set(['gif', 'webp'])
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The stored attachment a path names, when it names one.
|
|
18
|
+
*
|
|
19
|
+
* Every image the harness stores has a normalized copy at
|
|
20
|
+
* `attachments/v1/objects/<2>/<digest>`, and that is the path a model is shown for
|
|
21
|
+
* an attachment it received. For an animation this plugin captured, that copy is
|
|
22
|
+
* the lossy single frame and the file the person actually sent is held verbatim
|
|
23
|
+
* beside it, so a model that reaches for the advertised path is asking about the
|
|
24
|
+
* animation through the wrong door.
|
|
25
|
+
*
|
|
26
|
+
* The digest alone is enough to identify the object, so this matches on the leaf
|
|
27
|
+
* name rather than on a directory layout, which also accepts a bare
|
|
28
|
+
* `sha256:<digest>` — how the same object is named everywhere else in the harness.
|
|
29
|
+
*
|
|
30
|
+
* @param pathLike - the requested path, as written.
|
|
31
|
+
* @returns `sha256:<digest>`, or undefined when this is an ordinary file.
|
|
32
|
+
*/
|
|
33
|
+
export function storedAttachmentId(pathLike) {
|
|
34
|
+
if (typeof pathLike !== 'string') return undefined
|
|
35
|
+
const leaf = basename(pathLike.trim())
|
|
36
|
+
const bare = /^sha256:([0-9a-f]{64})$/i.exec(leaf)
|
|
37
|
+
const digest = bare?.[1] ?? (/^[0-9a-f]{64}$/i.test(leaf) ? leaf : undefined)
|
|
38
|
+
return digest === undefined ? undefined : `sha256:${digest.toLowerCase()}`
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
/**
|
|
16
42
|
* Read one animation and turn it into frames plus a description.
|
|
17
43
|
* @param ctx - the plugin context carrying `fs`, `attachments`, and optionally `llm`.
|
|
18
44
|
* @param settings - the resolved plugin configuration.
|
|
19
45
|
* @param args - validated tool arguments.
|
|
20
46
|
* @param exec - the tool execution context.
|
|
47
|
+
* @param pasted - the pasted-animation store, when that feature is enabled.
|
|
21
48
|
* @returns the canonical output value.
|
|
22
49
|
*/
|
|
23
|
-
export async function readAnimation(ctx, settings, args, exec) {
|
|
50
|
+
export async function readAnimation(ctx, settings, args, exec, pasted) {
|
|
24
51
|
if (typeof args.file_path !== 'string' || args.file_path.trim().length === 0) {
|
|
25
52
|
throw new Error('file_path must be a non-empty string')
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
const attachments = ctx.get('attachments')
|
|
29
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
|
+
}
|
|
30
63
|
await assertImageCapableRoute(ctx, exec, args.file_path)
|
|
31
64
|
|
|
32
65
|
const target = await ctx.fs.resolve(args.file_path, resolveOptions(exec))
|
|
@@ -40,7 +73,13 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
40
73
|
}
|
|
41
74
|
|
|
42
75
|
const byteCap = Math.min(settings.maxInputBytes, attachments.imageLimits.maxMessageImageBytes)
|
|
43
|
-
|
|
76
|
+
|
|
77
|
+
// Follow an advertised normalized copy back to the animation this plugin kept.
|
|
78
|
+
// Without this, reading the path the model was shown reports that the picture
|
|
79
|
+
// has one frame, which is true of that file and useless as an answer.
|
|
80
|
+
const kept = await keptOriginal(ctx, attachments, pasted, target)
|
|
81
|
+
const data = kept === undefined ? await ctx.fs.readBytes(target, exec.signal, byteCap) : kept.data
|
|
82
|
+
const path = kept === undefined ? target.displayPath : kept.path
|
|
44
83
|
|
|
45
84
|
// The deployment's own decoded-pixel ceiling, applied to both the probe and the
|
|
46
85
|
// frame extraction. Bounding the encoded bytes does not bound decoding: a 60 KB
|
|
@@ -52,7 +91,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
52
91
|
|
|
53
92
|
if (!SUPPORTED_FORMATS.has(animation.format)) {
|
|
54
93
|
throw new Error(
|
|
55
|
-
`cannot read "${
|
|
94
|
+
`cannot read "${path}" as an animation: it is ${animation.format}, and ` +
|
|
56
95
|
'read_animation only handles GIF and animated WebP. Use read_image for this file.',
|
|
57
96
|
)
|
|
58
97
|
}
|
|
@@ -75,7 +114,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
75
114
|
rendered.map((frame) => ({
|
|
76
115
|
data: frame.data,
|
|
77
116
|
mediaType: 'image/png',
|
|
78
|
-
name: `${basename(
|
|
117
|
+
name: `${basename(path)}#frame${frame.index}`,
|
|
79
118
|
})),
|
|
80
119
|
)
|
|
81
120
|
|
|
@@ -83,7 +122,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
83
122
|
|
|
84
123
|
const delays = animation.delaysMs
|
|
85
124
|
return {
|
|
86
|
-
path
|
|
125
|
+
path,
|
|
87
126
|
animation: {
|
|
88
127
|
mediaType: animation.mediaType,
|
|
89
128
|
width: animation.width,
|
|
@@ -124,6 +163,38 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
124
163
|
}
|
|
125
164
|
}
|
|
126
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The animation this plugin kept, for a path that names its normalized copy.
|
|
168
|
+
*
|
|
169
|
+
* Returns the original bytes and the path they actually came from, so the caller
|
|
170
|
+
* can report which file was read rather than which one was asked for. The returned
|
|
171
|
+
* path is the model's route back to the same file for a denser read.
|
|
172
|
+
*
|
|
173
|
+
* @param ctx - the plugin context.
|
|
174
|
+
* @param attachments - the mounted attachment service.
|
|
175
|
+
* @param pasted - the pasted-animation store, or undefined when disabled.
|
|
176
|
+
* @param target - the resolved requested path.
|
|
177
|
+
* @returns `{ data, path }`, or undefined when this is not a captured animation.
|
|
178
|
+
*/
|
|
179
|
+
async function keptOriginal(ctx, attachments, pasted, target) {
|
|
180
|
+
if (pasted === undefined) return undefined
|
|
181
|
+
const id = storedAttachmentId(target.displayPath)
|
|
182
|
+
if (id === undefined) return undefined
|
|
183
|
+
const entry = pasted.lookup(id)
|
|
184
|
+
if (entry?.original === undefined) return undefined
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const data = await collectBytes(attachments.readFileStream(entry.original.file))
|
|
188
|
+
const host =
|
|
189
|
+
typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(entry.original.file) : undefined
|
|
190
|
+
return { data, path: typeof host === 'string' && host.length > 0 ? host : target.displayPath }
|
|
191
|
+
} catch {
|
|
192
|
+
// An unreadable original is not a reason to fail: the requested path may still
|
|
193
|
+
// be a usable file in its own right.
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
127
198
|
/**
|
|
128
199
|
* Refuse the call when the resolved model route does not declare image input.
|
|
129
200
|
*
|
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/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()
|
|
@@ -24,10 +24,28 @@ import { fileURLToPath } from 'node:url'
|
|
|
24
24
|
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
25
25
|
const { loadSharp } = await import(pathToFileURL(join(REPO, 'lib', 'sharp.js')).href)
|
|
26
26
|
const { buildFilmstrip } = await import(pathToFileURL(join(REPO, 'lib', 'filmstrip.js')).href)
|
|
27
|
+
// The same header text the model is handed, so this preview cannot drift from it.
|
|
28
|
+
const { sheetHeader } = await import(pathToFileURL(join(REPO, 'lib', 'model.js')).href)
|
|
27
29
|
|
|
28
30
|
const dshHome = process.env.DSH_HOME ?? resolve(REPO, '..')
|
|
29
31
|
const stateFile = join(dshHome, 'read-animation', 'v1', 'pasted.jsonl')
|
|
30
32
|
const objects = join(dshHome, 'attachments', 'v1', 'objects')
|
|
33
|
+
const files = join(dshHome, 'attachments', 'v1', 'files')
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the store keeps a file's verbatim bytes.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors `ctx.attachments.fileHostPath`, which the plugin uses for the header's
|
|
39
|
+
* last line. This script has no mounted service, so the layout is spelled out
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
function keptFilePath(ref) {
|
|
46
|
+
const digest = String(ref.attachmentId).replace(/^sha256:/, '')
|
|
47
|
+
return join(files, digest.slice(0, 2), digest, ref.name)
|
|
48
|
+
}
|
|
31
49
|
|
|
32
50
|
/** Where a content-addressed attachment object lives. */
|
|
33
51
|
function objectPath(attachmentId) {
|
|
@@ -62,18 +80,8 @@ for (const [position, frame] of frames.entries()) {
|
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
const animation = note?.animation
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
animation && total > 1 && animation.durationMs > 0
|
|
68
|
-
? ` · ~${Math.round((total * 1000) / animation.durationMs)} fps`
|
|
69
|
-
: ''
|
|
70
|
-
const header = animation === undefined
|
|
71
|
-
? `${tiles.length} sampled frames of one animation, in reading order`
|
|
72
|
-
: `${animation.mediaType} · ${animation.width}x${animation.height} px · ${total} frames · ` +
|
|
73
|
-
`${animation.durationMs} ms${fps}${animation.minDelayMs === animation.maxDelayMs ? '' : ' · uneven timing'}` +
|
|
74
|
-
`${animation.loopsForever ? ' · loops forever' : ''}\n` +
|
|
75
|
-
`The ${tiles.length} tiles below are evenly spaced samples in reading order; ` +
|
|
76
|
-
`each frame's index, duration and start time is printed under it.`
|
|
83
|
+
const kept = record.entry.original?.file
|
|
84
|
+
const header = sheetHeader(animation, tiles.length, kept?.name === undefined ? undefined : keptFilePath(kept))
|
|
77
85
|
|
|
78
86
|
const sheet = await buildFilmstrip(sharp, { tiles, header, maxBytes: 4 * 1024 * 1024, maxDimension: 4096 })
|
|
79
87
|
await writeFile(out, sheet.data)
|