dsh-read-animation 0.1.0 → 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 +23 -2
- package/README.md +9 -8
- package/README.zh.md +6 -5
- package/lib/bytes.js +31 -0
- package/lib/display.js +2 -17
- package/lib/index.js +8 -4
- package/lib/model.js +34 -6
- package/lib/read.js +70 -5
- package/package.json +2 -2
- package/scripts/preview-sheet.mjs +16 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1 — 2026-10-14
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
6
27
|
|
|
7
28
|
### Fixed after an adversarial audit
|
|
8
29
|
|
package/README.md
CHANGED
|
@@ -147,18 +147,19 @@ the gaps are gaps.
|
|
|
147
147
|
dsh plugin --profile web add dsh-read-animation
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
Replace `web` with your profile name.
|
|
151
|
-
|
|
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`.
|
|
152
153
|
|
|
153
154
|
```sh
|
|
154
|
-
dsh plugin --profile web add
|
|
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
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
exists.)
|
|
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.)
|
|
162
163
|
|
|
163
164
|
Or from a checkout:
|
|
164
165
|
|
package/README.zh.md
CHANGED
|
@@ -121,15 +121,16 @@ frame 9 of 24 (sample 3) · starts at 450 ms · displayed for 50 ms
|
|
|
121
121
|
dsh plugin --profile web add dsh-read-animation
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`web` 换成你自己的 profile
|
|
124
|
+
`web` 换成你自己的 profile 名。**两条路都在真实 profile 里验证过**:装得上、`dsh plugin add` 会把它
|
|
125
|
+
注册成 bundle、`sharp` 的原生二进制也能从包自己的 `node_modules` 里解析出来。
|
|
125
126
|
|
|
126
127
|
```sh
|
|
127
|
-
dsh plugin --profile web add
|
|
128
|
+
dsh plugin --profile web add dsh-read-animation # npm
|
|
129
|
+
dsh plugin --profile web add github:NeoHotaru/dsh-read-animation#v0.1.0 # 钉住某个 tag
|
|
128
130
|
```
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
所以等上面那个 npm 包存在之后,npm 那条路更干净。)
|
|
132
|
+
git 那条会把整个仓库(含测试和 fixture)拷下来,所以 npm 更干净。(全新包名的**首次发布**会走
|
|
133
|
+
npm 的 staged publishing,所以刚发完那一小段时间里,registry 上可能还查不到那个版本号。)
|
|
133
134
|
|
|
134
135
|
或者从本地检出:
|
|
135
136
|
|
package/lib/bytes.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect a bounded byte stream into one array.
|
|
3
|
+
*
|
|
4
|
+
* `readFileStream` hands back an async iterable, and two callers here need the
|
|
5
|
+
* whole thing in one piece: the display path, which serves an original animation
|
|
6
|
+
* over the wire, and the tool, which decodes what the store kept verbatim.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-read-animation/bytes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drain an async iterable of chunks.
|
|
13
|
+
*
|
|
14
|
+
* @param stream - the chunks, in order.
|
|
15
|
+
* @returns one `Uint8Array` holding all of them.
|
|
16
|
+
*/
|
|
17
|
+
export async function collectBytes(stream) {
|
|
18
|
+
const chunks = []
|
|
19
|
+
let total = 0
|
|
20
|
+
for await (const chunk of stream) {
|
|
21
|
+
chunks.push(chunk)
|
|
22
|
+
total += chunk.byteLength
|
|
23
|
+
}
|
|
24
|
+
const out = new Uint8Array(total)
|
|
25
|
+
let at = 0
|
|
26
|
+
for (const chunk of chunks) {
|
|
27
|
+
out.set(chunk, at)
|
|
28
|
+
at += chunk.byteLength
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
package/lib/display.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* @module dsh-read-animation/display
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
import { collectBytes } from './bytes.js'
|
|
34
35
|
import { describe, warn, wrapServiceMethod } from './patch.js'
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -64,7 +65,7 @@ export function installOriginalDisplay(ctx, pasted) {
|
|
|
64
65
|
const entry = pasted.lookup(ref?.attachmentId)
|
|
65
66
|
if (entry?.original === undefined) return base(ref, signal)
|
|
66
67
|
try {
|
|
67
|
-
const data = await
|
|
68
|
+
const data = await collectBytes(attachments.readFileStream(entry.original.file, signal))
|
|
68
69
|
return { ref: displayedRef(ref, entry.original, data.byteLength), data }
|
|
69
70
|
} catch (error) {
|
|
70
71
|
// A missing or unreadable original must not cost the user their picture.
|
|
@@ -122,19 +123,3 @@ function displayedRef(ref, original, bytes) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
/** Drain a bounded byte stream into one array. */
|
|
126
|
-
async function collect(stream) {
|
|
127
|
-
const chunks = []
|
|
128
|
-
let total = 0
|
|
129
|
-
for await (const chunk of stream) {
|
|
130
|
-
chunks.push(chunk)
|
|
131
|
-
total += chunk.byteLength
|
|
132
|
-
}
|
|
133
|
-
const out = new Uint8Array(total)
|
|
134
|
-
let at = 0
|
|
135
|
-
for (const chunk of chunks) {
|
|
136
|
-
out.set(chunk, at)
|
|
137
|
-
at += chunk.byteLength
|
|
138
|
-
}
|
|
139
|
-
return out
|
|
140
|
-
}
|
package/lib/index.js
CHANGED
|
@@ -62,15 +62,19 @@ const DEFAULT_MAX_INPUT_BYTES = 64 * 1024 * 1024
|
|
|
62
62
|
export function apply(ctx, config = {}) {
|
|
63
63
|
const settings = resolveConfig(config)
|
|
64
64
|
ctx.inject(['attachments'], (imageCtx) => {
|
|
65
|
+
let pasted
|
|
65
66
|
if (settings.expandPastedAnimations) {
|
|
66
67
|
// Capture at admission (the last moment the animation's bytes exist) and
|
|
67
68
|
// hand the frames to the model at request time. The message is not touched.
|
|
68
|
-
|
|
69
|
+
pasted = createPastedStore(imageCtx)
|
|
69
70
|
installAdmissionCapture(imageCtx, settings, pasted)
|
|
70
71
|
const display = installOriginalDisplay(imageCtx, pasted)
|
|
71
72
|
installModelFrames(imageCtx, pasted, display)
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
+
// The store is handed to the tool as well: a model shown a normalized copy of
|
|
75
|
+
// a pasted animation reaches for that path first, and the tool can answer with
|
|
76
|
+
// the animation instead of reporting that the picture has one frame.
|
|
77
|
+
registerTool(imageCtx, settings, pasted)
|
|
74
78
|
})
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -102,7 +106,7 @@ function resolveConfig(config) {
|
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/** Build and register the `read_animation` tool definition. */
|
|
105
|
-
function registerTool(ctx, settings) {
|
|
109
|
+
function registerTool(ctx, settings, pasted) {
|
|
106
110
|
ctx.tools.register({
|
|
107
111
|
name: TOOL_NAME,
|
|
108
112
|
description: describeTool(settings),
|
|
@@ -116,7 +120,7 @@ function registerTool(ctx, settings) {
|
|
|
116
120
|
async execute(args, exec) {
|
|
117
121
|
const violations = validateArguments(args)
|
|
118
122
|
if (violations.length > 0) throw new Error(`invalid arguments: ${violations.join('; ')}`)
|
|
119
|
-
return readAnimation(ctx, settings, args, exec)
|
|
123
|
+
return readAnimation(ctx, settings, args, exec, pasted)
|
|
120
124
|
},
|
|
121
125
|
presentCall(args) {
|
|
122
126
|
return {
|
package/lib/model.js
CHANGED
|
@@ -163,7 +163,7 @@ async function sheetFor(ctx, entry, ref, target, signal, display) {
|
|
|
163
163
|
|
|
164
164
|
const sheet = await buildFilmstrip(sharp, {
|
|
165
165
|
tiles,
|
|
166
|
-
header:
|
|
166
|
+
header: sheetHeader(entry.note?.animation, tiles.length, hostPathOf(ctx, entry)),
|
|
167
167
|
// One tile may be as large as a single frame would have been for this caller:
|
|
168
168
|
// the sheet costs N tiles, not one picture multiplied by N.
|
|
169
169
|
tileMaxWidth: target?.width,
|
|
@@ -191,24 +191,52 @@ async function sheetFor(ctx, entry, ref, target, signal, display) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Where the animation itself is kept, when the store can say.
|
|
196
|
+
*
|
|
197
|
+
* The sheet names it so the model has a route to a denser read: eight samples is
|
|
198
|
+
* what fits in one image, and `read_animation(path, frames: […])` is how it asks
|
|
199
|
+
* for a specific moment afterwards. Without the path it has to go looking through
|
|
200
|
+
* the attachment store for the file behind the picture it was sent.
|
|
201
|
+
*/
|
|
202
|
+
function hostPathOf(ctx, entry) {
|
|
203
|
+
const attachments = ctx.get('attachments')
|
|
204
|
+
if (attachments === undefined || typeof attachments.fileHostPath !== 'function') return undefined
|
|
205
|
+
const path = attachments.fileHostPath(entry.original.file)
|
|
206
|
+
return typeof path === 'string' && path.length > 0 ? path : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
194
209
|
/**
|
|
195
210
|
* The sheet's own header: what this animation is, and how it was sampled.
|
|
196
211
|
*
|
|
197
|
-
* Kept short and split
|
|
198
|
-
*
|
|
199
|
-
*
|
|
212
|
+
* Kept short and split on purpose. A single long line is not wrapped by anything
|
|
213
|
+
* downstream — the sheet's own layout wraps each line to the canvas, and that
|
|
214
|
+
* works better from meaningful breaks than from one 200-character sentence.
|
|
215
|
+
*
|
|
216
|
+
* Exported so the preview script renders the same text the model is handed,
|
|
217
|
+
* instead of a second copy that can drift from this one.
|
|
218
|
+
*
|
|
219
|
+
* @param animation - the captured animation's facts, when available.
|
|
220
|
+
* @param sampled - how many tiles the sheet holds.
|
|
221
|
+
* @param originalPath - where the animation itself is kept, when known.
|
|
222
|
+
* @returns the header, lines separated by newlines.
|
|
200
223
|
*/
|
|
201
|
-
function
|
|
224
|
+
export function sheetHeader(animation, sampled, originalPath) {
|
|
202
225
|
if (animation === undefined) return `${sampled} sampled frames of one animation, in reading order`
|
|
203
226
|
const total = animation.frameCount
|
|
204
227
|
const fps =
|
|
205
228
|
total > 1 && animation.durationMs > 0 ? ` · ~${Math.round((total * 1000) / animation.durationMs)} fps` : ''
|
|
206
229
|
const timing = animation.minDelayMs === animation.maxDelayMs ? '' : ' · uneven timing'
|
|
207
230
|
const loops = animation.loopsForever === true ? ' · loops forever' : ''
|
|
231
|
+
const source =
|
|
232
|
+
originalPath === undefined
|
|
233
|
+
? `All ${total} frames are in the file this was pasted from`
|
|
234
|
+
: `All ${total} frames are at ${originalPath}`
|
|
208
235
|
return (
|
|
209
236
|
`${animation.mediaType} · ${animation.width}x${animation.height} px · ${total} frames · ` +
|
|
210
237
|
`${animation.durationMs} ms${fps}${timing}${loops}\n` +
|
|
211
238
|
`The ${sampled} tiles below are evenly spaced samples in reading order; ` +
|
|
212
|
-
`each frame's index, duration and start time is printed under it
|
|
239
|
+
`each frame's index, duration and start time is printed under it.\n` +
|
|
240
|
+
`${source} — read_animation on that path with explicit frame indices looks closer at any moment.`
|
|
213
241
|
)
|
|
214
242
|
}
|
package/lib/read.js
CHANGED
|
@@ -7,20 +7,47 @@
|
|
|
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
|
}
|
|
@@ -40,7 +67,13 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
40
67
|
}
|
|
41
68
|
|
|
42
69
|
const byteCap = Math.min(settings.maxInputBytes, attachments.imageLimits.maxMessageImageBytes)
|
|
43
|
-
|
|
70
|
+
|
|
71
|
+
// Follow an advertised normalized copy back to the animation this plugin kept.
|
|
72
|
+
// Without this, reading the path the model was shown reports that the picture
|
|
73
|
+
// has one frame, which is true of that file and useless as an answer.
|
|
74
|
+
const kept = await keptOriginal(ctx, attachments, pasted, target)
|
|
75
|
+
const data = kept === undefined ? await ctx.fs.readBytes(target, exec.signal, byteCap) : kept.data
|
|
76
|
+
const path = kept === undefined ? target.displayPath : kept.path
|
|
44
77
|
|
|
45
78
|
// The deployment's own decoded-pixel ceiling, applied to both the probe and the
|
|
46
79
|
// frame extraction. Bounding the encoded bytes does not bound decoding: a 60 KB
|
|
@@ -52,7 +85,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
52
85
|
|
|
53
86
|
if (!SUPPORTED_FORMATS.has(animation.format)) {
|
|
54
87
|
throw new Error(
|
|
55
|
-
`cannot read "${
|
|
88
|
+
`cannot read "${path}" as an animation: it is ${animation.format}, and ` +
|
|
56
89
|
'read_animation only handles GIF and animated WebP. Use read_image for this file.',
|
|
57
90
|
)
|
|
58
91
|
}
|
|
@@ -75,7 +108,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
75
108
|
rendered.map((frame) => ({
|
|
76
109
|
data: frame.data,
|
|
77
110
|
mediaType: 'image/png',
|
|
78
|
-
name: `${basename(
|
|
111
|
+
name: `${basename(path)}#frame${frame.index}`,
|
|
79
112
|
})),
|
|
80
113
|
)
|
|
81
114
|
|
|
@@ -83,7 +116,7 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
83
116
|
|
|
84
117
|
const delays = animation.delaysMs
|
|
85
118
|
return {
|
|
86
|
-
path
|
|
119
|
+
path,
|
|
87
120
|
animation: {
|
|
88
121
|
mediaType: animation.mediaType,
|
|
89
122
|
width: animation.width,
|
|
@@ -124,6 +157,38 @@ export async function readAnimation(ctx, settings, args, exec) {
|
|
|
124
157
|
}
|
|
125
158
|
}
|
|
126
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The animation this plugin kept, for a path that names its normalized copy.
|
|
162
|
+
*
|
|
163
|
+
* Returns the original bytes and the path they actually came from, so the caller
|
|
164
|
+
* can report which file was read rather than which one was asked for. The returned
|
|
165
|
+
* path is the model's route back to the same file for a denser read.
|
|
166
|
+
*
|
|
167
|
+
* @param ctx - the plugin context.
|
|
168
|
+
* @param attachments - the mounted attachment service.
|
|
169
|
+
* @param pasted - the pasted-animation store, or undefined when disabled.
|
|
170
|
+
* @param target - the resolved requested path.
|
|
171
|
+
* @returns `{ data, path }`, or undefined when this is not a captured animation.
|
|
172
|
+
*/
|
|
173
|
+
async function keptOriginal(ctx, attachments, pasted, target) {
|
|
174
|
+
if (pasted === undefined) return undefined
|
|
175
|
+
const id = storedAttachmentId(target.displayPath)
|
|
176
|
+
if (id === undefined) return undefined
|
|
177
|
+
const entry = pasted.lookup(id)
|
|
178
|
+
if (entry?.original === undefined) return undefined
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const data = await collectBytes(attachments.readFileStream(entry.original.file))
|
|
182
|
+
const host =
|
|
183
|
+
typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(entry.original.file) : undefined
|
|
184
|
+
return { data, path: typeof host === 'string' && host.length > 0 ? host : target.displayPath }
|
|
185
|
+
} catch {
|
|
186
|
+
// An unreadable original is not a reason to fail: the requested path may still
|
|
187
|
+
// be a usable file in its own right.
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
127
192
|
/**
|
|
128
193
|
* Refuse the call when the resolved model route does not declare image input.
|
|
129
194
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-animation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"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",
|
|
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",
|
|
59
59
|
"test:integration": "node test/integration/harness.mjs",
|
|
60
60
|
"test:all": "npm test && npm run test:integration",
|
|
61
61
|
"prepublishOnly": "npm test",
|
|
@@ -24,10 +24,24 @@ 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; the integration harness asserts the real accessor returns a real path.
|
|
41
|
+
*/
|
|
42
|
+
function keptFilePath(ref) {
|
|
43
|
+
return join(files, ref.name)
|
|
44
|
+
}
|
|
31
45
|
|
|
32
46
|
/** Where a content-addressed attachment object lives. */
|
|
33
47
|
function objectPath(attachmentId) {
|
|
@@ -62,18 +76,8 @@ for (const [position, frame] of frames.entries()) {
|
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
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.`
|
|
79
|
+
const kept = record.entry.original?.file
|
|
80
|
+
const header = sheetHeader(animation, tiles.length, kept?.name === undefined ? undefined : keptFilePath(kept))
|
|
77
81
|
|
|
78
82
|
const sheet = await buildFilmstrip(sharp, { tiles, header, maxBytes: 4 * 1024 * 1024, maxDimension: 4096 })
|
|
79
83
|
await writeFile(out, sheet.data)
|