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/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/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
|
@@ -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,15 +63,31 @@ 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
|
+
|
|
78
|
+
let pasted
|
|
65
79
|
if (settings.expandPastedAnimations) {
|
|
66
|
-
// Capture
|
|
67
|
-
//
|
|
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.
|
|
82
|
+
pasted = createPastedStore(imageCtx)
|
|
69
83
|
installAdmissionCapture(imageCtx, settings, pasted)
|
|
70
84
|
const display = installOriginalDisplay(imageCtx, pasted)
|
|
71
85
|
installModelFrames(imageCtx, pasted, display)
|
|
72
86
|
}
|
|
73
|
-
|
|
87
|
+
// The store is handed to the tool as well: a model shown a normalized copy of
|
|
88
|
+
// a pasted animation reaches for that path first, and the tool can answer with
|
|
89
|
+
// the animation instead of reporting that the picture has one frame.
|
|
90
|
+
registerTool(imageCtx, settings, pasted)
|
|
74
91
|
})
|
|
75
92
|
}
|
|
76
93
|
|
|
@@ -101,8 +118,49 @@ function resolveConfig(config) {
|
|
|
101
118
|
return settings
|
|
102
119
|
}
|
|
103
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
|
+
|
|
104
162
|
/** Build and register the `read_animation` tool definition. */
|
|
105
|
-
function registerTool(ctx, settings) {
|
|
163
|
+
function registerTool(ctx, settings, pasted) {
|
|
106
164
|
ctx.tools.register({
|
|
107
165
|
name: TOOL_NAME,
|
|
108
166
|
description: describeTool(settings),
|
|
@@ -116,7 +174,7 @@ function registerTool(ctx, settings) {
|
|
|
116
174
|
async execute(args, exec) {
|
|
117
175
|
const violations = validateArguments(args)
|
|
118
176
|
if (violations.length > 0) throw new Error(`invalid arguments: ${violations.join('; ')}`)
|
|
119
|
-
return readAnimation(ctx, settings, args, exec)
|
|
177
|
+
return readAnimation(ctx, settings, args, exec, pasted)
|
|
120
178
|
},
|
|
121
179
|
presentCall(args) {
|
|
122
180
|
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/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
|
*
|