@jini-ai/cms 0.5.1 → 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 +5 -0
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.js +3 -0
- package/dist/media/video-frames.d.ts +94 -0
- package/dist/media/video-frames.ffmpeg.d.ts +39 -0
- package/dist/media/video-frames.ffmpeg.js +261 -0
- package/dist/media/video-frames.js +97 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
|
|
3
8
|
## 0.5.1 — 2026-10-06
|
package/dist/media/index.d.ts
CHANGED
|
@@ -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
|
package/dist/media/index.js
CHANGED
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cms",
|
|
3
|
-
"version": "0.5.
|
|
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": {
|