@jini-ai/cms 0.5.0 → 0.5.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 CHANGED
@@ -1,5 +1,14 @@
1
+ ## 0.5.2 — 2026-10-06
2
+
3
+ - Video previews (`@jini-ai/cms/media`): `planVideoFrames` / `planVideoContactSheets` / `getVideoFrameEdge` / `parseVideoProbe` plus the `VideoFrameExtractor` port and an ffmpeg adapter (`createFfmpegVideoFrameExtractor`, `findVideoBinaries`, `runLowPriorityVideoProcess`). Default 16 frames, up to 64 (frames past 16 shrink to bound image tokens), `every`/`start` spacing, explicit `at` times, or up to 4 contact sheets of ≤16 tiles with a timestamp map.
4
+ - Bounded by design: ≤64 MiB input, ≤512 KiB per frame, ≤2 MiB per sheet, ≤8 MiB total; ffmpeg runs `nice -n 19`, `-threads 1`, ≤8 source decoders per process in serial batches, one extraction at a time, 60 s shared deadline. Uses `-fps_mode passthrough` (ffmpeg ≥ 5.1; `-vsync` is gone in ffmpeg 9).
5
+
1
6
  # Changelog
2
7
 
8
+ ## 0.5.1 — 2026-10-06
9
+
10
+ - Navigation: the `menus_update_menu_tree` schema declares the `entryType`/`lastKnownHref` hints on entry targets, and `updateMenuTree` carries a stored hint forward when the same item still targets the same entry and the submitted target omits it, so echoing a read no longer marks the menu changed.
11
+
3
12
  ## 0.5.0 — 2026-10-05
4
13
 
5
14
  ### BREAKING
@@ -48,4 +48,6 @@ export type { RegisterTransformRequired, GetLatestTransformDefinitionRequired, I
48
48
  export type { IsBlobUnreferencedRequired, TombstoneBlobRequired, RunBlobGcDeletePassRequired, RunBlobGcUnlinkPassRequired, RunBlobGcCycleRequired } from "./blob-gc.js";
49
49
  export { rollbackUploadedMedia } from "./media-service.js";
50
50
  export type { MediaRowCleanupDeps } from "./media-service.js";
51
+ export { VIDEO_DEFAULT_FRAMES, VIDEO_MAX_FRAMES, VIDEO_MAX_EDGE_PX, VIDEO_MAX_INPUT_BYTES, VIDEO_MAX_FRAME_BYTES, VIDEO_MAX_OUTPUT_BYTES, VIDEO_MAX_SHEET_BYTES, VIDEO_MAX_SHEETS, VIDEO_EXTRACTION_TIMEOUT_MS, planVideoFrames, parseVideoProbe, getVideoFrameEdge, planVideoContactSheets, type VideoMetadata, type VideoFrame, type VideoFrameOptions, type VideoFrameResult, type VideoFrameExtractor, type VideoSheetTile, type VideoSheetLayout, type VideoContactSheet, } from './video-frames.js';
52
+ export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
51
53
  //# sourceMappingURL=index.d.ts.map
@@ -31,4 +31,7 @@ export { mediaAgentToolCatalog, } from "./agent-tools.js";
31
31
  */
32
32
  export { buildMediaRegistrations, mediaDerivedRisk, } from "./tool-registrations.js";
33
33
  export { rollbackUploadedMedia } from "./media-service.js";
34
+ // Video previews: policy/port plus a bounded optional-host codec adapter.
35
+ export { VIDEO_DEFAULT_FRAMES, VIDEO_MAX_FRAMES, VIDEO_MAX_EDGE_PX, VIDEO_MAX_INPUT_BYTES, VIDEO_MAX_FRAME_BYTES, VIDEO_MAX_OUTPUT_BYTES, VIDEO_MAX_SHEET_BYTES, VIDEO_MAX_SHEETS, VIDEO_EXTRACTION_TIMEOUT_MS, planVideoFrames, parseVideoProbe, getVideoFrameEdge, planVideoContactSheets, } from './video-frames.js';
36
+ export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
34
37
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,94 @@
1
+ /** Video preview contract and sampling policy, independent of a host, codec or AI provider. */
2
+ export declare const VIDEO_MAX_FRAMES = 64;
3
+ export declare const VIDEO_DEFAULT_FRAMES = 16;
4
+ export declare const VIDEO_MAX_EDGE_PX = 768;
5
+ export declare const VIDEO_MAX_INPUT_BYTES: number;
6
+ export declare const VIDEO_MAX_FRAME_BYTES: number;
7
+ /** Aggregate raw JPEG payload budget; base64 expands this to about 10.67 MiB plus metadata. */
8
+ export declare const VIDEO_MAX_OUTPUT_BYTES: number;
9
+ export declare const VIDEO_MAX_SHEET_BYTES: number;
10
+ export declare const VIDEO_MAX_SHEETS = 4;
11
+ export declare const VIDEO_EXTRACTION_TIMEOUT_MS = 60000;
12
+ export interface VideoMetadata {
13
+ readonly duration: number;
14
+ readonly width: number;
15
+ readonly height: number;
16
+ readonly codec: string;
17
+ readonly container: string;
18
+ readonly sizeBytes: number;
19
+ readonly hasAudio: boolean;
20
+ }
21
+ export interface VideoFrame {
22
+ readonly at: number;
23
+ readonly mimeType: 'image/jpeg';
24
+ readonly bytes: Uint8Array;
25
+ }
26
+ export interface VideoSheetTile {
27
+ readonly index: number;
28
+ readonly row: number;
29
+ readonly column: number;
30
+ readonly at: number;
31
+ }
32
+ export interface VideoSheetLayout {
33
+ readonly columns: number;
34
+ readonly rows: number;
35
+ readonly tileEdgePx: number;
36
+ /** One-based row-major positions; timestamp order and repetitions are preserved. */
37
+ readonly tiles: readonly VideoSheetTile[];
38
+ }
39
+ export interface VideoContactSheet extends VideoSheetLayout {
40
+ readonly mimeType: 'image/jpeg';
41
+ readonly bytes: Uint8Array;
42
+ }
43
+ export interface VideoFrameOptions {
44
+ readonly layout?: 'frames' | 'sheet';
45
+ readonly frames?: number;
46
+ readonly at?: readonly number[];
47
+ readonly every?: number;
48
+ readonly start?: number;
49
+ readonly signal?: AbortSignal;
50
+ }
51
+ export type VideoFrameResult = {
52
+ readonly ok: true;
53
+ readonly metadata: VideoMetadata;
54
+ readonly frames: readonly VideoFrame[];
55
+ readonly sheets?: readonly VideoContactSheet[];
56
+ } | {
57
+ readonly ok: false;
58
+ readonly reason: 'unavailable' | 'busy' | 'too-large' | 'timeout' | 'canceled' | 'invalid';
59
+ readonly message: string;
60
+ };
61
+ /** Implementations return <=64 sampled frames: separate JPEGs <=512KiB or <=4 sheets <=2MiB.
62
+ * The count-dependent pixel policy and <=8MiB aggregate budget apply in both modes.
63
+ * Sheet mode returns frames: [] plus sheets; separate mode omits sheets. Overflow fails in full.
64
+ * Inputs are authorized bytes, never caller paths. No provider or CMS-owner concepts live here. */
65
+ export interface VideoFrameExtractor {
66
+ extract(required: {
67
+ bytes: Uint8Array;
68
+ }, optional: VideoFrameOptions): Promise<VideoFrameResult>;
69
+ }
70
+ /** Keep sixteen square 768px images worth of pixels (about 12.6k tokens at pixels/750).
71
+ * Even pixel sizes are codec-friendly; flooring never increases the budget. */
72
+ export declare function getVideoFrameEdge({ frameCount }: {
73
+ frameCount: number;
74
+ }, _optional?: Record<string, never>): number;
75
+ /** Grid pixels, including black unused cells, share the separate-frame pixel budget.
76
+ * Metadata provides labels without requiring a host font or ffmpeg drawtext support. */
77
+ export declare function planVideoContactSheets({ times }: {
78
+ times: readonly number[];
79
+ }, _optional?: Record<string, never>): VideoSheetLayout[];
80
+ /** Avoid seeking to exact EOF, which commonly has no decodable frame. Preserve explicit order,
81
+ * including repeated timestamps, so image positions continue to match the requested positions.
82
+ * With every and no count, stop when the next timestamp cannot fit (always emit the first clamped
83
+ * position). An explicit count preserves the requested count, clamping positions beyond the clip.
84
+ * start only affects every; at overrides count/start, but at + every is invalid. */
85
+ export declare function planVideoFrames({ duration }: {
86
+ duration: number;
87
+ }, { frames, at, every, start }?: Pick<VideoFrameOptions, 'frames' | 'at' | 'every' | 'start'>): number[];
88
+ /** Parse only the ffprobe fields requested by the adapter. File size is measured from authorized
89
+ * input bytes, never from a possibly stale container header. Cover art is not a video stream. */
90
+ export declare function parseVideoProbe({ output, sizeBytes }: {
91
+ output: string;
92
+ sizeBytes: number;
93
+ }, _optional?: Record<string, never>): VideoMetadata;
94
+ //# sourceMappingURL=video-frames.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { type VideoFrameExtractor } from './video-frames.js';
3
+ export interface VideoBinaries {
4
+ readonly nice: string;
5
+ readonly ffmpeg: string;
6
+ readonly ffprobe: string;
7
+ }
8
+ export type VideoBinaryFinder = (required: Record<string, never>, optional: Record<string, never>) => Promise<VideoBinaries | undefined>;
9
+ export type VideoProcessRunner = (required: {
10
+ command: string;
11
+ args: readonly string[];
12
+ }, optional: {
13
+ timeoutMs: number;
14
+ maxOutputBytes: number;
15
+ signal?: AbortSignal;
16
+ }) => Promise<Uint8Array>;
17
+ /** Discovery never executes a version/probe command. Configured paths are host configuration,
18
+ * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
19
+ export declare const findVideoBinaries: VideoBinaryFinder;
20
+ /** detached creates a group including nice and its eventual executable; killing the group prevents
21
+ * a wrapper from leaving a decoder alive. Waiting for close keeps the extraction lock until exit. */
22
+ export declare const runLowPriorityVideoProcess: ({ command, args, spawnProcess, killGroup }: {
23
+ command: string;
24
+ args: readonly string[];
25
+ spawnProcess?: typeof spawn;
26
+ killGroup?: (required: {
27
+ pid: number;
28
+ }, optional: Record<string, never>) => void;
29
+ }, { timeoutMs, maxOutputBytes, signal }: {
30
+ timeoutMs: number;
31
+ maxOutputBytes: number;
32
+ signal?: AbortSignal;
33
+ }) => Promise<Uint8Array>;
34
+ export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs }: {
35
+ findBinaries?: VideoBinaryFinder;
36
+ runProcess?: VideoProcessRunner;
37
+ nowMs?: (required: Record<string, never>, optional: Record<string, never>) => number;
38
+ }, _optional?: Record<string, never>): VideoFrameExtractor;
39
+ //# sourceMappingURL=video-frames.ffmpeg.d.ts.map
@@ -0,0 +1,261 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { delimiter, isAbsolute, join } from 'node:path';
7
+ import { sniffContentType } from './content-type-sniffer.js';
8
+ import { getVideoFrameEdge, parseVideoProbe, planVideoFrames, planVideoContactSheets, VIDEO_EXTRACTION_TIMEOUT_MS, VIDEO_MAX_SHEET_BYTES, VIDEO_MAX_FRAME_BYTES, VIDEO_MAX_INPUT_BYTES, VIDEO_MAX_OUTPUT_BYTES } from './video-frames.js';
9
+ class VideoProcessError extends Error {
10
+ reason;
11
+ constructor(reason, message) {
12
+ super(message);
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ async function executable(required, { configured } = {}) {
17
+ const candidates = configured ? [configured] : [
18
+ ...(process.env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
19
+ join('/opt/homebrew/bin', required.name), join('/usr/local/bin', required.name), join('/usr/bin', required.name),
20
+ ];
21
+ for (const candidate of candidates) {
22
+ if (!isAbsolute(candidate))
23
+ continue;
24
+ try {
25
+ await access(candidate, constants.X_OK);
26
+ if ((await stat(candidate)).isFile())
27
+ return candidate;
28
+ }
29
+ catch { /* Try the next host executable. */ }
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Discovery never executes a version/probe command. Configured paths are host configuration,
34
+ * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
35
+ export const findVideoBinaries = async (_required, _optional) => {
36
+ if (process.platform === 'win32')
37
+ return undefined; // Never silently omit the low-priority guarantee.
38
+ const nice = await executable({ name: 'nice' }, {});
39
+ const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: process.env['FFMPEG_PATH'] });
40
+ const ffprobe = await executable({ name: 'ffprobe' }, { configured: process.env['FFPROBE_PATH'] });
41
+ return nice && ffmpeg && ffprobe ? { nice, ffmpeg, ffprobe } : undefined;
42
+ };
43
+ /** detached creates a group including nice and its eventual executable; killing the group prevents
44
+ * a wrapper from leaving a decoder alive. Waiting for close keeps the extraction lock until exit. */
45
+ export const runLowPriorityVideoProcess = async ({ command, args, spawnProcess = spawn, killGroup = ({ pid }) => { process.kill(-pid, 'SIGKILL'); } }, { timeoutMs, maxOutputBytes, signal }) => {
46
+ if (signal?.aborted)
47
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
48
+ if (timeoutMs <= 0)
49
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawnProcess(command, [...args], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
52
+ const chunks = [];
53
+ let bytes = 0;
54
+ let stopped;
55
+ let settled = false;
56
+ const stop = (error) => {
57
+ if (stopped || settled)
58
+ return;
59
+ stopped = error;
60
+ try {
61
+ if (child.pid === undefined)
62
+ child.kill('SIGKILL');
63
+ else
64
+ killGroup({ pid: child.pid }, {});
65
+ }
66
+ catch {
67
+ child.kill('SIGKILL');
68
+ }
69
+ };
70
+ const timer = setTimeout(() => stop(new VideoProcessError('timeout', 'Video preview timed out.')), timeoutMs);
71
+ const abort = () => stop(new VideoProcessError('canceled', 'Video preview canceled.'));
72
+ const finish = (error) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ signal?.removeEventListener('abort', abort);
78
+ if (error)
79
+ reject(error);
80
+ else
81
+ resolve(Buffer.concat(chunks));
82
+ };
83
+ child.stdout?.on('data', (chunk) => {
84
+ if (stopped)
85
+ return;
86
+ bytes += chunk.length;
87
+ if (bytes > maxOutputBytes) {
88
+ stop(new VideoProcessError('invalid', 'Video preview exceeded its output limit.'));
89
+ return;
90
+ }
91
+ chunks.push(chunk);
92
+ });
93
+ // Drain diagnostics but do not retain unbounded stderr or expose host paths to the model.
94
+ child.stderr?.resume();
95
+ child.once('error', (error) => finish(new VideoProcessError(error.code === 'ENOENT' || error.code === 'EACCES' ? 'unavailable' : 'invalid', 'Video preview process could not start.')));
96
+ child.once('close', (code) => finish(stopped ?? (code === 0 ? undefined : new VideoProcessError('invalid', 'Video preview could not decode this clip.'))));
97
+ signal?.addEventListener('abort', abort, { once: true });
98
+ if (signal?.aborted)
99
+ abort();
100
+ });
101
+ };
102
+ // Shared across ALL adapter instances: a second catalog/tenant cannot run a second ffmpeg in this
103
+ // process. Fail busy instead of holding arbitrary numbers of uploaded videos in a pending queue.
104
+ let extracting = false;
105
+ const UNAVAILABLE = 'video preview unavailable on this host (nice, ffmpeg and ffprobe are required).';
106
+ // Eight amortizes process/seek overhead while bounding expensive source decoders to
107
+ // one eighth of the 64-frame maximum. Thread caps alone do not bound decoder memory.
108
+ // This is a decoder-count limit, not a measured host RSS guarantee.
109
+ const SOURCE_BATCH_SIZE = 8;
110
+ const localInputFlags = ['-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mov,matroska,webm'];
111
+ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now() }, _optional = {}) {
112
+ return { extract: async ({ bytes }, options) => {
113
+ if (bytes.byteLength > VIDEO_MAX_INPUT_BYTES)
114
+ return { ok: false, reason: 'too-large', message: `Video preview input exceeds ${VIDEO_MAX_INPUT_BYTES} bytes.` };
115
+ const type = sniffContentType({ bytes });
116
+ if (type !== 'video/mp4' && type !== 'video/webm')
117
+ return { ok: false, reason: 'invalid', message: 'Video preview requires stored MP4 or WebM bytes.' };
118
+ if (extracting)
119
+ return { ok: false, reason: 'busy', message: 'Video preview is busy. Try again after the current extraction finishes.' };
120
+ extracting = true;
121
+ const deadline = nowMs({}, {}) + VIDEO_EXTRACTION_TIMEOUT_MS;
122
+ let directory;
123
+ try {
124
+ if (options.signal?.aborted)
125
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
126
+ if (options.layout !== undefined && options.layout !== 'frames' && options.layout !== 'sheet')
127
+ throw new VideoProcessError('invalid', 'Video layout must be frames or sheet.');
128
+ const binaries = await findBinaries({}, {});
129
+ if (!binaries)
130
+ return { ok: false, reason: 'unavailable', message: UNAVAILABLE };
131
+ directory = await mkdtemp(join(tmpdir(), 'video-preview-'));
132
+ const input = join(directory, 'input');
133
+ await writeFile(input, bytes, { mode: 0o600, flag: 'wx' });
134
+ const checkDeadline = () => {
135
+ if (options.signal?.aborted)
136
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
137
+ if (nowMs({}, {}) >= deadline)
138
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
139
+ };
140
+ const run = (required, optional) => {
141
+ checkDeadline();
142
+ const timeoutMs = deadline - nowMs({}, {});
143
+ if (timeoutMs <= 0)
144
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
145
+ return runProcess({ command: binaries.nice, args: ['-n', '19', required.binary, ...required.args] }, { timeoutMs, maxOutputBytes: optional.maxOutputBytes, ...(options.signal ? { signal: options.signal } : {}) });
146
+ };
147
+ const probe = await run({ binary: binaries.ffprobe, args: ['-v', 'error', ...localInputFlags, '-show_entries', 'format=duration,format_name:stream=codec_type,codec_name,width,height,duration:stream_disposition=attached_pic', '-of', 'json', '-i', input] }, { maxOutputBytes: 64 * 1024 });
148
+ const metadata = parseVideoProbe({ output: Buffer.from(probe).toString('utf8'), sizeBytes: bytes.byteLength }, {});
149
+ const times = planVideoFrames({ duration: metadata.duration }, options);
150
+ const sheetLayouts = options.layout === 'sheet' ? planVideoContactSheets({ times }, {}) : undefined;
151
+ const edge = sheetLayouts?.[0]?.tileEdgePx ?? getVideoFrameEdge({ frameCount: times.length }, {});
152
+ const imageCount = sheetLayouts?.length ?? times.length;
153
+ const perImageLimit = sheetLayouts ? VIDEO_MAX_SHEET_BYTES : VIDEO_MAX_FRAME_BYTES;
154
+ // Preallocate a fair share for each output, so all file limits together fit the total budget.
155
+ const fileLimit = Math.min(perImageLimit, Math.floor(VIDEO_MAX_OUTPUT_BYTES / imageCount));
156
+ const paths = Array.from({ length: imageCount }, (_, index) => join(directory, `image-${index}.jpg`));
157
+ const tilePaths = sheetLayouts ? times.map((_, index) => join(directory, `tile-${index}.jpg`)) : paths;
158
+ // Intermediates have their own bounded allocation; never spend a sheet's 2 MiB per tile.
159
+ const tileLimit = sheetLayouts ? Math.min(VIDEO_MAX_FRAME_BYTES, Math.floor(VIDEO_MAX_OUTPUT_BYTES / times.length)) : fileLimit;
160
+ const ffmpegArgs = () => ['-hide_banner', '-loglevel', 'error', '-nostdin', '-filter_threads', '1', '-filter_complex_threads', '1'];
161
+ // Framerate mode is per output (ffmpeg >=5.1); preserve each selected frame
162
+ // for both source extraction and contact-sheet assembly without resync.
163
+ const appendOutput = ({ args, label, path, limit }) => args.push('-map', label, '-an', '-sn', '-dn', '-frames:v', '1', '-c:v', 'mjpeg', '-threads', '1', '-q:v', '5', '-fs', String(limit), '-f', 'mjpeg', '-fps_mode', 'passthrough', path);
164
+ const readImage = async ({ path, perImageLimit, fileLimit }) => {
165
+ checkDeadline();
166
+ // -fs is packet-granular: check size before allocating and refuse overshoot.
167
+ const size = (await stat(path)).size;
168
+ checkDeadline();
169
+ if (size > perImageLimit)
170
+ throw new VideoProcessError('invalid', 'Video preview exceeded its output limit.');
171
+ if (size > fileLimit)
172
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
173
+ const image = await readFile(path);
174
+ checkDeadline();
175
+ if (image.length < 4 || image[0] !== 255 || image[1] !== 216 || image[image.length - 2] !== 255 || image[image.length - 1] !== 217) {
176
+ throw new VideoProcessError('invalid', 'Video preview returned no JPEG frame.');
177
+ }
178
+ return image;
179
+ };
180
+ const scale = `scale=w='min(${edge},iw)':h='min(${edge},ih)':force_original_aspect_ratio=decrease`;
181
+ const images = [];
182
+ let tileBytes = 0;
183
+ // Await exit and validate this batch before starting another: <=8 source contexts alive.
184
+ for (let offset = 0; offset < times.length; offset += SOURCE_BATCH_SIZE) {
185
+ checkDeadline();
186
+ const batch = times.slice(offset, offset + SOURCE_BATCH_SIZE);
187
+ const args = ffmpegArgs();
188
+ // Seek each source input before opening it; no full-clip select/decode scan.
189
+ for (const at of batch)
190
+ args.push(...localInputFlags, '-ss', String(at), '-i', input);
191
+ const filters = batch.map((_, index) => `[${index}:V:0]trim=end_frame=1,setpts=PTS-STARTPTS,${scale},setsar=1${sheetLayouts ? `,pad=${edge}:${edge}:(ow-iw)/2:(oh-ih)/2:color=black` : ''}[tile${index}]`);
192
+ args.push('-filter_complex', filters.join(';'));
193
+ for (let index = 0; index < batch.length; index++)
194
+ appendOutput({ args, label: `[tile${index}]`, path: tilePaths[offset + index], limit: tileLimit });
195
+ // Raw MJPEG with one frame writes one JPEG through the file muxer (-fs applies).
196
+ await run({ binary: binaries.ffmpeg, args }, { maxOutputBytes: 64 * 1024 });
197
+ checkDeadline();
198
+ for (let index = 0; index < batch.length; index++) {
199
+ const image = await readImage({ path: tilePaths[offset + index], perImageLimit: VIDEO_MAX_FRAME_BYTES, fileLimit: tileLimit });
200
+ tileBytes += image.byteLength;
201
+ if (tileBytes > VIDEO_MAX_OUTPUT_BYTES)
202
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
203
+ if (!sheetLayouts)
204
+ images.push(image); // Sheet tiles remain on disk, not retained in memory.
205
+ }
206
+ }
207
+ if (sheetLayouts) {
208
+ let offset = 0;
209
+ let outputBytes = 0;
210
+ for (const [index, sheet] of sheetLayouts.entries()) {
211
+ checkDeadline();
212
+ const inputs = tilePaths.slice(offset, offset + sheet.tiles.length);
213
+ const args = ffmpegArgs();
214
+ // These decoders see only small, validated JPEGs, never the source video.
215
+ for (const path of inputs)
216
+ args.push('-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mjpeg', '-f', 'mjpeg', '-i', path);
217
+ const filters = inputs.map((_, tile) => `[${tile}:v:0]trim=end_frame=1,setpts=PTS-STARTPTS[tile${tile}]`);
218
+ const labels = inputs.map((_, tile) => `[tile${tile}]`).join('');
219
+ const positions = sheet.tiles.map(tile => `${(tile.column - 1) * edge}_${(tile.row - 1) * edge}`).join('|');
220
+ // Pad the grid if unused bottom-right cells do not establish the full extent.
221
+ filters.push(`${labels}${inputs.length === 1 ? 'null' : `xstack=inputs=${inputs.length}:layout=${positions}:fill=black:shortest=1`},pad=${sheet.columns * edge}:${sheet.rows * edge}:0:0:color=black[sheet]`);
222
+ args.push('-filter_complex', filters.join(';'));
223
+ appendOutput({ args, label: '[sheet]', path: paths[index], limit: fileLimit });
224
+ await run({ binary: binaries.ffmpeg, args }, { maxOutputBytes: 64 * 1024 });
225
+ const image = await readImage({ path: paths[index], perImageLimit, fileLimit });
226
+ outputBytes += image.byteLength;
227
+ if (outputBytes > VIDEO_MAX_OUTPUT_BYTES)
228
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
229
+ images.push(image);
230
+ // Retire tiles as soon as their sheet is verified; finally still cleans on any failure.
231
+ for (const path of inputs) {
232
+ checkDeadline();
233
+ await rm(path);
234
+ }
235
+ offset += inputs.length;
236
+ }
237
+ }
238
+ checkDeadline();
239
+ if (sheetLayouts)
240
+ return { ok: true, metadata, frames: [], sheets: sheetLayouts.map((sheet, index) => ({ ...sheet, mimeType: 'image/jpeg', bytes: images[index] })) };
241
+ return { ok: true, metadata, frames: times.map((at, index) => ({ at, mimeType: 'image/jpeg', bytes: images[index] })) };
242
+ }
243
+ catch (error) {
244
+ const reason = error instanceof VideoProcessError ? error.reason : 'invalid';
245
+ return { ok: false, reason, message: reason === 'unavailable' ? UNAVAILABLE : error instanceof VideoProcessError ? error.message : 'Video preview could not read or decode this clip.' };
246
+ }
247
+ finally {
248
+ try {
249
+ if (directory)
250
+ await rm(directory, { recursive: true, force: true });
251
+ }
252
+ catch {
253
+ return { ok: false, reason: 'invalid', message: 'Video preview temporary files could not be removed.' };
254
+ }
255
+ finally {
256
+ extracting = false;
257
+ }
258
+ }
259
+ } };
260
+ }
261
+ //# sourceMappingURL=video-frames.ffmpeg.js.map
@@ -0,0 +1,97 @@
1
+ /** Video preview contract and sampling policy, independent of a host, codec or AI provider. */
2
+ export const VIDEO_MAX_FRAMES = 64;
3
+ export const VIDEO_DEFAULT_FRAMES = 16;
4
+ export const VIDEO_MAX_EDGE_PX = 768;
5
+ export const VIDEO_MAX_INPUT_BYTES = 64 * 1024 * 1024;
6
+ export const VIDEO_MAX_FRAME_BYTES = 512 * 1024;
7
+ /** Aggregate raw JPEG payload budget; base64 expands this to about 10.67 MiB plus metadata. */
8
+ export const VIDEO_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
9
+ export const VIDEO_MAX_SHEET_BYTES = 2 * 1024 * 1024;
10
+ export const VIDEO_MAX_SHEETS = 4;
11
+ // One shared deadline: serial source batches and JPEG sheet assembly need launch/seek
12
+ // headroom at nice 19. This remains a hard bound, not a per-process allowance.
13
+ export const VIDEO_EXTRACTION_TIMEOUT_MS = 60_000;
14
+ /** Keep sixteen square 768px images worth of pixels (about 12.6k tokens at pixels/750).
15
+ * Even pixel sizes are codec-friendly; flooring never increases the budget. */
16
+ export function getVideoFrameEdge({ frameCount }, _optional = {}) {
17
+ if (!Number.isInteger(frameCount) || frameCount < 1 || frameCount > VIDEO_MAX_FRAMES)
18
+ throw new Error('Frame count must be an integer from 1 to 64.');
19
+ return 2 * Math.floor(VIDEO_MAX_EDGE_PX * Math.min(1, Math.sqrt(VIDEO_DEFAULT_FRAMES / frameCount)) / 2);
20
+ }
21
+ /** Grid pixels, including black unused cells, share the separate-frame pixel budget.
22
+ * Metadata provides labels without requiring a host font or ffmpeg drawtext support. */
23
+ export function planVideoContactSheets({ times }, _optional = {}) {
24
+ if (!Array.isArray(times) || times.length < 1 || times.length > VIDEO_MAX_FRAMES || times.some(time => typeof time !== 'number' || !Number.isFinite(time) || time < 0)) {
25
+ throw new Error('Sheets require 1 to 64 finite nonnegative timestamps.');
26
+ }
27
+ const groups = [];
28
+ for (let offset = 0; offset < times.length; offset += 16) {
29
+ const group = times.slice(offset, offset + 16);
30
+ const columns = Math.min(4, Math.ceil(Math.sqrt(group.length)));
31
+ const rows = Math.ceil(group.length / columns);
32
+ groups.push({ columns, rows, tileEdgePx: 0, tiles: group.map((at, index) => ({ index: index + 1, row: Math.floor(index / columns) + 1, column: index % columns + 1, at })) });
33
+ }
34
+ const frameCount = groups.reduce((sum, group) => sum + group.columns * group.rows, 0);
35
+ const tileEdgePx = getVideoFrameEdge({ frameCount }, {});
36
+ return groups.map(group => ({ ...group, tileEdgePx }));
37
+ }
38
+ /** Avoid seeking to exact EOF, which commonly has no decodable frame. Preserve explicit order,
39
+ * including repeated timestamps, so image positions continue to match the requested positions.
40
+ * With every and no count, stop when the next timestamp cannot fit (always emit the first clamped
41
+ * position). An explicit count preserves the requested count, clamping positions beyond the clip.
42
+ * start only affects every; at overrides count/start, but at + every is invalid. */
43
+ export function planVideoFrames({ duration }, { frames, at, every, start = 0 } = {}) {
44
+ if (!Number.isFinite(duration) || duration <= 0)
45
+ throw new Error('Video duration must be positive and finite.');
46
+ if (frames !== undefined && (typeof frames !== 'number' || !Number.isFinite(frames)))
47
+ throw new Error('Frame count must be finite.');
48
+ if (frames !== undefined && frames < 0)
49
+ throw new Error('Frame count must be nonnegative.');
50
+ if (at !== undefined && (!Array.isArray(at) || !at.length || at.some(time => typeof time !== 'number' || !Number.isFinite(time)))) {
51
+ throw new Error('at must contain finite timestamps in seconds.');
52
+ }
53
+ if (at?.some(time => time < 0))
54
+ throw new Error('at timestamps must be nonnegative.');
55
+ if (every !== undefined && (typeof every !== 'number' || !Number.isFinite(every) || every <= 0))
56
+ throw new Error('every must be a positive finite number of seconds.');
57
+ if (typeof start !== 'number' || !Number.isFinite(start) || start < 0)
58
+ throw new Error('start must be a nonnegative finite number of seconds.');
59
+ if (at !== undefined && every !== undefined)
60
+ throw new Error('every cannot be combined with at.');
61
+ const inset = Math.min(0.1, duration / 10);
62
+ const end = duration - inset;
63
+ if (at !== undefined) {
64
+ return at.slice(0, VIDEO_MAX_FRAMES).map(time => Math.max(0, Math.min(end, time)));
65
+ }
66
+ const count = Math.max(1, Math.min(VIDEO_MAX_FRAMES, Math.floor(frames ?? (every !== undefined ? VIDEO_MAX_FRAMES : VIDEO_DEFAULT_FRAMES))));
67
+ if (every !== undefined) {
68
+ const times = [];
69
+ for (let index = 0; index < count; index++) {
70
+ const time = start + index * every;
71
+ // Multiplication can round a fitting boundary just above end. Only tolerate arithmetic
72
+ // roundoff; a materially later timestamp must not add an EOF sample when count is omitted.
73
+ const tolerance = Number.EPSILON * Math.max(end, Math.abs(time)) * 4;
74
+ if (frames === undefined && index > 0 && (!Number.isFinite(time) || time - end > tolerance))
75
+ break;
76
+ times.push(Math.min(end, time));
77
+ }
78
+ return times;
79
+ }
80
+ if (count === 1)
81
+ return [duration / 2];
82
+ return Array.from({ length: count }, (_, index) => inset + (end - inset) * index / (count - 1));
83
+ }
84
+ /** Parse only the ffprobe fields requested by the adapter. File size is measured from authorized
85
+ * input bytes, never from a possibly stale container header. Cover art is not a video stream. */
86
+ export function parseVideoProbe({ output, sizeBytes }, _optional = {}) {
87
+ const raw = JSON.parse(output);
88
+ const streams = Array.isArray(raw?.streams) ? raw.streams : [];
89
+ const video = streams.find(stream => stream.codec_type === 'video' && stream.disposition?.attached_pic !== 1);
90
+ const formatDuration = Number(raw?.format?.duration);
91
+ const duration = Number.isFinite(formatDuration) && formatDuration > 0 ? formatDuration : Number(video?.duration);
92
+ if (!video || !Number.isFinite(duration) || duration <= 0 || !Number.isInteger(video.width) || !Number.isInteger(video.height) || video.width <= 0 || video.height <= 0) {
93
+ throw new Error('Video metadata is missing or invalid.');
94
+ }
95
+ return { duration, width: video.width, height: video.height, codec: video.codec_name ?? 'unknown', container: raw.format?.format_name ?? 'unknown', sizeBytes, hasAudio: streams.some(stream => stream.codec_type === 'audio') };
96
+ }
97
+ //# sourceMappingURL=video-frames.js.map
@@ -84,7 +84,15 @@ const NAV_TARGET_SCHEMA = {
84
84
  type: "object",
85
85
  additionalProperties: false,
86
86
  required: ["kind", "entryId"],
87
- properties: { kind: { const: "entryRef" }, entryId: { type: "string", description: "Target entry id (e.g. a page or post)." } },
87
+ properties: {
88
+ kind: { const: "entryRef" },
89
+ entryId: { type: "string", description: "Target entry id (e.g. a page or post)." },
90
+ // Declared so a read can be echoed back unchanged: these were stored on page links but absent
91
+ // here, so every agent save dropped them (dry run 2026-10-05). The save also keeps the stored
92
+ // value when omitted for the same item id + entryId (`carryForwardEntryHints`).
93
+ entryType: { type: "string", description: "Content-type hint, e.g. 'page' or 'post'. Copy it unchanged from the read; omit for a new link." },
94
+ lastKnownHref: { type: "string", description: "The entry's last known public URL, e.g. '/about'. Copy it unchanged from the read; omit for a new link." },
95
+ },
88
96
  },
89
97
  {
90
98
  type: "object",
@@ -207,7 +215,7 @@ export const menusAgentToolCatalog = [
207
215
  name: "menus_update_menu_tree",
208
216
  description: "Replaces an existing menu's whole item tree (and optionally its title/slug) in one version-guarded write. " +
209
217
  "Always a whole-tree replace — there is no per-item patch operation, so the submitted tree must include every " +
210
- "item the menu keeps. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
218
+ "item the menu keeps, each with its fields copied unchanged from the read. To delete a menu, use trash_item with entityType 'menu' (it moves to the Trash and can be restored).",
211
219
  sideEffects: "mutates-durable-state",
212
220
  authorization: { permission: "admin.menus.update" },
213
221
  inputSchema: {
@@ -198,6 +198,18 @@ export interface UpdateMenuTreeRequired {
198
198
  export interface UpdateMenuTreeOptional {
199
199
  limits?: TreeValidationLimits | undefined;
200
200
  }
201
+ /**
202
+ * Whole-tree saves arrive from callers that may not know an item's stored hint fields (an agent
203
+ * echoing a read through a schema, an older editor build). Dropping them on every save made an
204
+ * untouched menu look changed (dry run 2026-10-05: ~30 header page links lost `entryType: "page"`
205
+ * on one assistant save and the menu showed pending-to-live). So a hint is kept when the same item
206
+ * id still points at the same entry and the submitted target leaves that hint out; a re-pointed
207
+ * link or an explicitly submitted hint is taken as sent.
208
+ */
209
+ export declare function carryForwardEntryHints({ previous, next }: {
210
+ previous: readonly NavItemNode[];
211
+ next: readonly NavItemNode[];
212
+ }, _optional?: {}): NavItemNode[];
201
213
  /**
202
214
  * Replaces a menu's whole item tree, guarded by optimistic concurrency on the
203
215
  * entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
@@ -327,6 +327,44 @@ export async function createMenu(required, optional = {}) {
327
327
  }));
328
328
  return { menu };
329
329
  }
330
+ /** The stored `entryRef` hint fields a whole-tree save carries forward when the submitted target omits them. */
331
+ const ENTRY_HINT_KEYS = ["entryType", "lastKnownHref"];
332
+ function entryTargetsById(nodes, into = new Map()) {
333
+ for (const node of nodes) {
334
+ if (node.target?.kind === "entryRef")
335
+ into.set(node.id, node.target);
336
+ if (node.children)
337
+ entryTargetsById(node.children, into);
338
+ }
339
+ return into;
340
+ }
341
+ function withStoredHints(target, stored) {
342
+ if (target.kind !== "entryRef" || !stored || stored.entryId !== target.entryId)
343
+ return target;
344
+ const merged = { ...target };
345
+ for (const key of ENTRY_HINT_KEYS) {
346
+ if (merged[key] === undefined && stored[key] !== undefined)
347
+ merged[key] = stored[key];
348
+ }
349
+ return merged;
350
+ }
351
+ /**
352
+ * Whole-tree saves arrive from callers that may not know an item's stored hint fields (an agent
353
+ * echoing a read through a schema, an older editor build). Dropping them on every save made an
354
+ * untouched menu look changed (dry run 2026-10-05: ~30 header page links lost `entryType: "page"`
355
+ * on one assistant save and the menu showed pending-to-live). So a hint is kept when the same item
356
+ * id still points at the same entry and the submitted target leaves that hint out; a re-pointed
357
+ * link or an explicitly submitted hint is taken as sent.
358
+ */
359
+ export function carryForwardEntryHints({ previous, next }, _optional = {}) {
360
+ const stored = entryTargetsById(previous);
361
+ const walk = (nodes) => nodes.map((node) => ({
362
+ ...node,
363
+ target: withStoredHints(node.target, stored.get(node.id)),
364
+ children: node.children ? walk(node.children) : undefined,
365
+ }));
366
+ return walk(next);
367
+ }
330
368
  /**
331
369
  * Replaces a menu's whole item tree, guarded by optimistic concurrency on the
332
370
  * entry `version` (matches the host's `updatePost` pattern): `expectedVersion` must equal the version
@@ -369,7 +407,10 @@ export async function updateMenuTree(required, optional = {}) {
369
407
  throw new MenuConflictError({ message: `slug '${slug}' already exists` });
370
408
  }
371
409
  }
372
- const items = validateAndCloneTree({ items: input.items }, optional.limits);
410
+ const items = carryForwardEntryHints({
411
+ previous: existing.doc.items,
412
+ next: validateAndCloneTree({ items: input.items }, optional.limits),
413
+ });
373
414
  const menu = {
374
415
  ...existing,
375
416
  title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cms",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound adapters in domain-specific subpaths.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {