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/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 Host then destroys them: `admitPromptContent` runs every image
10
- * through the attachment normalizer, which is single-frame by construction, and
11
- * the source is not kept. By the time any tool can see the attachment it is one
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
- * `admitPromptContent` is the last moment the original bytes exist. This module
15
- * wraps it to decode the animation and store the frames — and then returns the
16
- * original result **unchanged**, so the message a person wrote stays exactly what
17
- * they wrote. `./display.js` serves the animation itself back to the UI, and
18
- * `./model.js` turns the frames into the model's own copy of that image.
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 the attachment service's prompt admission so a pasted animation's frames
43
- * are captured before the source bytes are discarded.
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.admitPromptContent !== 'function') {
57
- warn(ctx, 'attachments.admitPromptContent is not a function; a pasted animation stays a single frame')
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, 'admitPromptContent', (base) =>
62
- async function admitPromptContentCapturingAnimations(content) {
63
- // The caller's result is never modified. Capture is best-effort and runs on
64
- // the side; if it fails, the message is still exactly what was sent.
65
- const admitted = await base(content)
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, content, admitted)
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 admitted
70
+ return refs
72
71
  },
73
72
  )
74
73
 
75
74
  if (!patch.installed) {
76
- warn(ctx, 'the admission wrapper did not take effect; a pasted animation stays a single frame')
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 admitted reference.
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 content - the parts as submitted, carrying the original bytes.
91
- * @param admitted - the parts the service returned, carrying the references.
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, content, admitted) {
94
- if (!Array.isArray(content) || !Array.isArray(admitted)) return
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
- content.forEach((part, index) => {
98
- if (part?.type === 'image' && ANIMATED_MEDIA_TYPES.has(part.mediaType)) claimed.push(index)
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 = content.filter((part) => part?.type === 'image').length - claimed.length
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 target = admitted[index]
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 = content[index]
115
- const bytes = Buffer.from(part.data, 'base64')
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 refs = await ctx.attachments.saveImages(
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: refs.map((ref) => ({
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 collect(attachments.readFileStream(entry.original.file, signal))
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 at admission (the last moment the animation's bytes exist) and
67
- // hand the frames to the model at request time. The message is not touched.
68
- const pasted = createPastedStore(imageCtx)
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
- registerTool(imageCtx, settings)
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: headerFor(entry.note?.animation, tiles.length),
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 in two on purpose. A single long line is not wrapped by
198
- * anything downstream — the sheet's own layout wraps each line to the canvas, and
199
- * that works better from meaningful breaks than from one 200-character sentence.
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 headerFor(animation, sampled) {
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
  *