@f5-sales-demo/xcsh 20.15.2 → 20.16.0
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/package.json +8 -8
- package/src/config/settings-schema.ts +18 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog.generated.ts +2 -2
- package/src/media/ffmpeg.ts +399 -0
- package/src/media/ingest.ts +269 -0
- package/src/media/markdown-resolver.ts +81 -0
- package/src/media/network.ts +208 -0
- package/src/media/sniff.ts +73 -0
- package/src/media/types.ts +137 -0
- package/src/modes/components/assistant-message.ts +14 -2
- package/src/modes/components/gutter-block.ts +5 -0
- package/src/modes/components/media-message.ts +272 -0
- package/src/modes/controllers/command-controller.ts +17 -0
- package/src/modes/controllers/event-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +16 -1
- package/src/prompts/tools/display-media.md +8 -0
- package/src/sandbox/enforce.ts +19 -0
- package/src/sdk.ts +5 -0
- package/src/session/blob-store.ts +1 -0
- package/src/session/messages.ts +4 -0
- package/src/session/session-manager.ts +8 -1
- package/src/slash-commands/builtin-registry.ts +14 -0
- package/src/tools/display-image.ts +23 -63
- package/src/tools/display-media-renderer.ts +96 -0
- package/src/tools/display-media.ts +103 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/renderers.ts +2 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.16.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "20.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "20.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "20.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "20.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
70
|
-
"@f5-sales-demo/xcsh-stats": "20.
|
|
64
|
+
"@f5-sales-demo/pi-agent-core": "20.16.0",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "20.16.0",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "20.16.0",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "20.16.0",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "20.16.0",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "20.16.0",
|
|
70
|
+
"@f5-sales-demo/xcsh-stats": "20.16.0",
|
|
71
71
|
"@mozilla/readability": "^0.6",
|
|
72
72
|
"@sinclair/typebox": "^0.34",
|
|
73
73
|
"@xterm/headless": "^6.0",
|
|
@@ -433,6 +433,24 @@ export const SETTINGS_SCHEMA = {
|
|
|
433
433
|
ui: { tab: "appearance", label: "Block Images", description: "Prevent images from being sent to LLM providers" },
|
|
434
434
|
},
|
|
435
435
|
|
|
436
|
+
"media.autoplay": {
|
|
437
|
+
type: "boolean",
|
|
438
|
+
default: true,
|
|
439
|
+
ui: { tab: "appearance", label: "Autoplay Media", description: "Play rich media once when it becomes visible" },
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
"media.reducedMotion": {
|
|
443
|
+
type: "boolean",
|
|
444
|
+
default: false,
|
|
445
|
+
ui: { tab: "appearance", label: "Reduced Motion", description: "Disable automatic rich-media playback" },
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
"media.fpsCap": {
|
|
449
|
+
type: "number",
|
|
450
|
+
default: 12,
|
|
451
|
+
ui: { tab: "appearance", label: "Media FPS Cap", description: "Maximum rich-media playback frame rate" },
|
|
452
|
+
},
|
|
453
|
+
|
|
436
454
|
"images.pasteDir": {
|
|
437
455
|
type: "string",
|
|
438
456
|
default: ".",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.16.0",
|
|
21
|
+
"commit": "5c1d6f26773e45cf387e740af8c7f17bc8088b33",
|
|
22
|
+
"shortCommit": "5c1d6f2",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.16.0",
|
|
25
|
+
"commitDate": "2026-08-15T05:11:57Z",
|
|
26
|
+
"buildDate": "2026-08-15T05:38:17.354Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/5c1d6f26773e45cf387e740af8c7f17bc8088b33",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.16.0"
|
|
33
33
|
};
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ConsoleCatalogData } from "./console-catalog-types";
|
|
4
4
|
|
|
5
|
-
export const CONSOLE_CATALOG_VERSION = "
|
|
5
|
+
export const CONSOLE_CATALOG_VERSION = "189c19b1401c9b97008fc0638b96c5805bfb3c62";
|
|
6
6
|
|
|
7
7
|
export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
|
|
8
|
-
version: "
|
|
8
|
+
version: "189c19b1401c9b97008fc0638b96c5805bfb3c62",
|
|
9
9
|
workflows: {
|
|
10
10
|
"address-allocator/create":
|
|
11
11
|
'---\nschema: urn:xcsh:console:workflow:v1\nid: address-allocator-create\nlabel: Create IP Address Allocators\nresource: address-allocator\noperation: create\npreconditions:\n - user_logged_in\n - "role_minimum: admin"\nparams:\n name:\n required: true\n description: IP Address Allocators name (lowercase alphanumeric and hyphens)\n example: example-address-allocator\n address_allocator_mode:\n required: true\n description: Address Allocator Mode\n allocation_unit:\n required: false\n description: Allocation Unit\n default: 0\n address_pool:\n required: false\n description: Address Pool\n default: value\n address_allocation_scheme:\n required: false\n description: "Server-required: Field should be not nil"\n default: value\nsteps:\n - id: navigate-to-list\n action: navigate\n url: /web/workspaces/multi-cloud-network-connect/manage/networking/legacy_network_configuration/address_allocators\n wait_for: text(\'IP Address Allocators\')\n description: Navigate to IP Address Allocators list page\n - id: click-add-tab\n action: click\n selector: text(\'Add IP Address Allocator\')\n wait_for: textbox[name=\'Name\']\n description: Click Add IP Address Allocator to open the create form\n - id: fill-name\n action: fill\n selector: textbox[name=\'Name\']\n value: "{name}"\n description: Enter Name\n - id: select-address_allocator_mode\n action: select\n selector: listbox\n context: Address Allocator Mode section\n value: "{address_allocator_mode}"\n description: Select Address Allocator Mode\n - id: fill-allocation_unit\n action: fill\n selector: spinbutton[name=\'Allocation Unit\']\n value: "{allocation_unit}"\n description: Set Allocation Unit\n - id: fill-address_pool\n action: fill\n selector: ngx-datatable input.form-control\n context: Address Pool table\n value: "{address_pool}"\n description: Enter Address Pool in the existing table row (no Add Item needed — the table ships one empty row)\n - id: select-address_allocation_scheme\n action: select\n selector: listbox\n context: Address Allocation Scheme section\n value: "{address_allocation_scheme}"\n description: Select Address Allocation Scheme\n - id: save\n action: click\n selector: "[class*=\'save-bt\'],[class*=\'submit-button\']"\n context: footer\n wait_for: text(\'{name}\')\n wait_timeout_ms: 30000\n description: Save/submit the form (union selector matches save-bt OR submit-button)\npostconditions:\n - resource_list_page_visible\n - "resource_name_in_list: {name}"\nmetadata:\n confidence: inferred\n discovered_at: 2026-06-24\n console_version: "2025.06"\n notes: Auto-generated by scripts/generate-workflows.ts from api-specs-enriched field metadata.\n',
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ProbedMedia {
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
durationMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DecodedRasterFrame {
|
|
12
|
+
data: Buffer;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DecodeRasterFramesOptions {
|
|
17
|
+
ffmpegPath?: string;
|
|
18
|
+
fpsCap?: number;
|
|
19
|
+
maxFrames?: number;
|
|
20
|
+
maxOutputBytes?: number;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
webpmuxPath?: string;
|
|
24
|
+
animDumpPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runWithInput(
|
|
28
|
+
command: string[],
|
|
29
|
+
input: Buffer,
|
|
30
|
+
timeoutMs: number,
|
|
31
|
+
maxOutputBytes: number,
|
|
32
|
+
signal?: AbortSignal,
|
|
33
|
+
): Promise<Buffer> {
|
|
34
|
+
const child = Bun.spawn(command, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
35
|
+
let timedOut = false;
|
|
36
|
+
let aborted = false;
|
|
37
|
+
const onAbort = () => {
|
|
38
|
+
aborted = true;
|
|
39
|
+
child.kill();
|
|
40
|
+
};
|
|
41
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
42
|
+
if (signal?.aborted) onAbort();
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
child.kill();
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
const stderrPromise = new Response(child.stderr).text();
|
|
48
|
+
const chunks: Buffer[] = [];
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
try {
|
|
51
|
+
child.stdin.write(input);
|
|
52
|
+
child.stdin.end();
|
|
53
|
+
const reader = child.stdout.getReader();
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
const chunk = Buffer.from(value);
|
|
58
|
+
bytes += chunk.byteLength;
|
|
59
|
+
if (bytes > maxOutputBytes) {
|
|
60
|
+
child.kill();
|
|
61
|
+
throw new Error("decoded media output exceeded its bound");
|
|
62
|
+
}
|
|
63
|
+
chunks.push(chunk);
|
|
64
|
+
}
|
|
65
|
+
const exitCode = await child.exited;
|
|
66
|
+
const stderr = await stderrPromise;
|
|
67
|
+
if (aborted) throw signal?.reason ?? new Error("media decoder aborted");
|
|
68
|
+
if (timedOut) throw new Error("media decoder timed out");
|
|
69
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with code ${exitCode}`);
|
|
70
|
+
return Buffer.concat(chunks, bytes);
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal?.removeEventListener("abort", onAbort);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function runWithoutInput(
|
|
78
|
+
command: string[],
|
|
79
|
+
timeoutMs: number,
|
|
80
|
+
maxOutputBytes: number,
|
|
81
|
+
signal?: AbortSignal,
|
|
82
|
+
): Promise<Buffer> {
|
|
83
|
+
const child = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
let aborted = false;
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
aborted = true;
|
|
88
|
+
child.kill();
|
|
89
|
+
};
|
|
90
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
91
|
+
if (signal?.aborted) onAbort();
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
timedOut = true;
|
|
94
|
+
child.kill();
|
|
95
|
+
}, timeoutMs);
|
|
96
|
+
const stderrPromise = new Response(child.stderr).text();
|
|
97
|
+
const chunks: Buffer[] = [];
|
|
98
|
+
let bytes = 0;
|
|
99
|
+
try {
|
|
100
|
+
const reader = child.stdout.getReader();
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) break;
|
|
104
|
+
const chunk = Buffer.from(value);
|
|
105
|
+
bytes += chunk.byteLength;
|
|
106
|
+
if (bytes > maxOutputBytes) {
|
|
107
|
+
child.kill();
|
|
108
|
+
throw new Error("media helper output exceeded its bound");
|
|
109
|
+
}
|
|
110
|
+
chunks.push(chunk);
|
|
111
|
+
}
|
|
112
|
+
const exitCode = await child.exited;
|
|
113
|
+
const stderr = await stderrPromise;
|
|
114
|
+
if (aborted) throw signal?.reason ?? new Error("media helper aborted");
|
|
115
|
+
if (timedOut) throw new Error("media helper timed out");
|
|
116
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with code ${exitCode}`);
|
|
117
|
+
return Buffer.concat(chunks, bytes);
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
signal?.removeEventListener("abort", onAbort);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isAnimatedWebp(input: Buffer): boolean {
|
|
125
|
+
return (
|
|
126
|
+
input.length >= 16 &&
|
|
127
|
+
input.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
128
|
+
input.subarray(8, 12).toString("ascii") === "WEBP" &&
|
|
129
|
+
input.includes(Buffer.from("ANIM"))
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function decodeAnimatedWebpFallback(
|
|
134
|
+
input: Buffer,
|
|
135
|
+
options: {
|
|
136
|
+
ffmpegPath: string;
|
|
137
|
+
webpmuxPath: string;
|
|
138
|
+
animDumpPath: string;
|
|
139
|
+
maxFrames: number;
|
|
140
|
+
maxOutputBytes: number;
|
|
141
|
+
timeoutMs: number;
|
|
142
|
+
fpsCap: number;
|
|
143
|
+
signal?: AbortSignal;
|
|
144
|
+
},
|
|
145
|
+
): Promise<DecodedRasterFrame[] | null> {
|
|
146
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "xcsh-webp-"));
|
|
147
|
+
try {
|
|
148
|
+
const sourcePath = path.join(directory, "input.webp");
|
|
149
|
+
const framesPath = path.join(directory, "frames");
|
|
150
|
+
await fs.mkdir(framesPath);
|
|
151
|
+
await fs.writeFile(sourcePath, input);
|
|
152
|
+
const info = (
|
|
153
|
+
await runWithoutInput(
|
|
154
|
+
[options.webpmuxPath, "-info", sourcePath],
|
|
155
|
+
options.timeoutMs,
|
|
156
|
+
1024 * 1024,
|
|
157
|
+
options.signal,
|
|
158
|
+
)
|
|
159
|
+
).toString("utf8");
|
|
160
|
+
const canvas = /Canvas size:\s*(\d+)\s*x\s*(\d+)/u.exec(info);
|
|
161
|
+
const count = /Number of frames:\s*(\d+)/u.exec(info);
|
|
162
|
+
const width = Number(canvas?.[1]);
|
|
163
|
+
const height = Number(canvas?.[2]);
|
|
164
|
+
const frameCount = Number(count?.[1]);
|
|
165
|
+
if (
|
|
166
|
+
!Number.isInteger(width) ||
|
|
167
|
+
!Number.isInteger(height) ||
|
|
168
|
+
!Number.isInteger(frameCount) ||
|
|
169
|
+
width <= 0 ||
|
|
170
|
+
height <= 0 ||
|
|
171
|
+
frameCount <= 0 ||
|
|
172
|
+
frameCount > options.maxFrames ||
|
|
173
|
+
width * height * 4 * frameCount > options.maxOutputBytes
|
|
174
|
+
) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const nativeDurations = info
|
|
178
|
+
.split("\n")
|
|
179
|
+
.map(line => /^\s*\d+:\s+\d+\s+\d+\s+\S+\s+\d+\s+\d+\s+(\d+)/u.exec(line))
|
|
180
|
+
.filter((match): match is RegExpExecArray => match !== null)
|
|
181
|
+
.map(match => Number(match[1]));
|
|
182
|
+
await runWithoutInput(
|
|
183
|
+
[options.animDumpPath, "-folder", framesPath, "-prefix", "frame_", "-pam", sourcePath],
|
|
184
|
+
options.timeoutMs,
|
|
185
|
+
1024 * 1024,
|
|
186
|
+
options.signal,
|
|
187
|
+
);
|
|
188
|
+
const framePaths = (await fs.readdir(framesPath))
|
|
189
|
+
.filter(name => /^frame_\d+\.pam$/u.test(name))
|
|
190
|
+
.sort(
|
|
191
|
+
(left, right) =>
|
|
192
|
+
Number(left.slice("frame_".length, -".pam".length)) -
|
|
193
|
+
Number(right.slice("frame_".length, -".pam".length)),
|
|
194
|
+
)
|
|
195
|
+
.slice(0, options.maxFrames);
|
|
196
|
+
if (framePaths.length !== frameCount) return null;
|
|
197
|
+
const frames: DecodedRasterFrame[] = [];
|
|
198
|
+
let outputBytes = 0;
|
|
199
|
+
const minimumDuration = Math.ceil(1000 / options.fpsCap);
|
|
200
|
+
for (const [index, name] of framePaths.entries()) {
|
|
201
|
+
const pam = await fs.readFile(path.join(framesPath, name));
|
|
202
|
+
const remaining = options.maxOutputBytes - outputBytes;
|
|
203
|
+
if (remaining <= 0) return null;
|
|
204
|
+
const png = await runWithInput(
|
|
205
|
+
[
|
|
206
|
+
options.ffmpegPath,
|
|
207
|
+
"-v",
|
|
208
|
+
"error",
|
|
209
|
+
"-nostdin",
|
|
210
|
+
"-f",
|
|
211
|
+
"image2pipe",
|
|
212
|
+
"-vcodec",
|
|
213
|
+
"pam",
|
|
214
|
+
"-i",
|
|
215
|
+
"pipe:0",
|
|
216
|
+
"-frames:v",
|
|
217
|
+
"1",
|
|
218
|
+
"-f",
|
|
219
|
+
"image2pipe",
|
|
220
|
+
"-vcodec",
|
|
221
|
+
"png",
|
|
222
|
+
"pipe:1",
|
|
223
|
+
],
|
|
224
|
+
pam,
|
|
225
|
+
options.timeoutMs,
|
|
226
|
+
remaining,
|
|
227
|
+
options.signal,
|
|
228
|
+
);
|
|
229
|
+
outputBytes += png.byteLength;
|
|
230
|
+
frames.push({ data: png, durationMs: Math.max(minimumDuration, nativeDurations[index] ?? minimumDuration) });
|
|
231
|
+
}
|
|
232
|
+
return frames;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
} finally {
|
|
236
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function splitPngStream(output: Buffer, maxFrames: number): Buffer[] {
|
|
241
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
242
|
+
const frames: Buffer[] = [];
|
|
243
|
+
let offset = 0;
|
|
244
|
+
while (offset < output.length) {
|
|
245
|
+
if (!output.subarray(offset, offset + signature.length).equals(signature)) {
|
|
246
|
+
throw new Error("FFmpeg emitted an invalid PNG stream");
|
|
247
|
+
}
|
|
248
|
+
const start = offset;
|
|
249
|
+
offset += signature.length;
|
|
250
|
+
let complete = false;
|
|
251
|
+
while (offset + 12 <= output.length) {
|
|
252
|
+
const length = output.readUInt32BE(offset);
|
|
253
|
+
const chunkEnd = offset + 12 + length;
|
|
254
|
+
if (chunkEnd > output.length) throw new Error("FFmpeg emitted a truncated PNG frame");
|
|
255
|
+
const type = output.subarray(offset + 4, offset + 8).toString("ascii");
|
|
256
|
+
offset = chunkEnd;
|
|
257
|
+
if (type === "IEND") {
|
|
258
|
+
frames.push(output.subarray(start, offset));
|
|
259
|
+
if (frames.length > maxFrames) throw new Error("decoded media exceeded its frame bound");
|
|
260
|
+
complete = true;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!complete) throw new Error("FFmpeg emitted an incomplete PNG frame");
|
|
265
|
+
}
|
|
266
|
+
return frames;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function probeMedia(
|
|
270
|
+
input: Buffer,
|
|
271
|
+
ffprobePath = "ffprobe",
|
|
272
|
+
timeoutMs = 10_000,
|
|
273
|
+
signal?: AbortSignal,
|
|
274
|
+
): Promise<ProbedMedia | null> {
|
|
275
|
+
try {
|
|
276
|
+
const output = await runWithInput(
|
|
277
|
+
[
|
|
278
|
+
ffprobePath,
|
|
279
|
+
"-v",
|
|
280
|
+
"error",
|
|
281
|
+
"-select_streams",
|
|
282
|
+
"v:0",
|
|
283
|
+
"-show_entries",
|
|
284
|
+
"stream=width,height:format=duration",
|
|
285
|
+
"-of",
|
|
286
|
+
"json",
|
|
287
|
+
"-i",
|
|
288
|
+
"pipe:0",
|
|
289
|
+
],
|
|
290
|
+
input,
|
|
291
|
+
timeoutMs,
|
|
292
|
+
1024 * 1024,
|
|
293
|
+
signal,
|
|
294
|
+
);
|
|
295
|
+
const parsed = JSON.parse(output.toString("utf8")) as {
|
|
296
|
+
streams?: Array<{ width?: number; height?: number }>;
|
|
297
|
+
format?: { duration?: string };
|
|
298
|
+
};
|
|
299
|
+
const stream = parsed.streams?.[0];
|
|
300
|
+
const durationSeconds = Number(parsed.format?.duration);
|
|
301
|
+
return {
|
|
302
|
+
width: stream?.width,
|
|
303
|
+
height: stream?.height,
|
|
304
|
+
durationMs:
|
|
305
|
+
Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.round(durationSeconds * 1000) : undefined,
|
|
306
|
+
};
|
|
307
|
+
} catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function decodePoster(
|
|
313
|
+
input: Buffer,
|
|
314
|
+
ffmpegPath = "ffmpeg",
|
|
315
|
+
timeoutMs = 15_000,
|
|
316
|
+
signal?: AbortSignal,
|
|
317
|
+
): Promise<Buffer | null> {
|
|
318
|
+
try {
|
|
319
|
+
return await runWithInput(
|
|
320
|
+
[
|
|
321
|
+
ffmpegPath,
|
|
322
|
+
"-v",
|
|
323
|
+
"error",
|
|
324
|
+
"-nostdin",
|
|
325
|
+
"-i",
|
|
326
|
+
"pipe:0",
|
|
327
|
+
"-an",
|
|
328
|
+
"-frames:v",
|
|
329
|
+
"1",
|
|
330
|
+
"-vf",
|
|
331
|
+
"scale='min(1920,iw)':-2",
|
|
332
|
+
"-f",
|
|
333
|
+
"image2pipe",
|
|
334
|
+
"-vcodec",
|
|
335
|
+
"png",
|
|
336
|
+
"pipe:1",
|
|
337
|
+
],
|
|
338
|
+
input,
|
|
339
|
+
timeoutMs,
|
|
340
|
+
25 * 1024 * 1024,
|
|
341
|
+
signal,
|
|
342
|
+
);
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function decodeRasterFrames(
|
|
349
|
+
input: Buffer,
|
|
350
|
+
options: DecodeRasterFramesOptions = {},
|
|
351
|
+
): Promise<DecodedRasterFrame[] | null> {
|
|
352
|
+
const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
353
|
+
const fpsCap = Math.max(1, Math.min(60, Math.floor(options.fpsCap ?? 12)));
|
|
354
|
+
const maxFrames = Math.max(1, Math.floor(options.maxFrames ?? 720));
|
|
355
|
+
const maxOutputBytes = Math.max(1, Math.floor(options.maxOutputBytes ?? 100 * 1024 * 1024));
|
|
356
|
+
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? 30_000));
|
|
357
|
+
try {
|
|
358
|
+
const output = await runWithInput(
|
|
359
|
+
[
|
|
360
|
+
ffmpegPath,
|
|
361
|
+
"-v",
|
|
362
|
+
"error",
|
|
363
|
+
"-nostdin",
|
|
364
|
+
"-i",
|
|
365
|
+
"pipe:0",
|
|
366
|
+
"-an",
|
|
367
|
+
"-vf",
|
|
368
|
+
`fps=${fpsCap},scale='min(1920,iw)':-2`,
|
|
369
|
+
"-frames:v",
|
|
370
|
+
String(maxFrames),
|
|
371
|
+
"-f",
|
|
372
|
+
"image2pipe",
|
|
373
|
+
"-vcodec",
|
|
374
|
+
"png",
|
|
375
|
+
"pipe:1",
|
|
376
|
+
],
|
|
377
|
+
input,
|
|
378
|
+
timeoutMs,
|
|
379
|
+
maxOutputBytes,
|
|
380
|
+
options.signal,
|
|
381
|
+
);
|
|
382
|
+
const durationMs = Math.ceil(1000 / fpsCap);
|
|
383
|
+
const frames = splitPngStream(output, maxFrames);
|
|
384
|
+
if (frames.length === 0) return null;
|
|
385
|
+
return frames.map(data => ({ data, durationMs }));
|
|
386
|
+
} catch {
|
|
387
|
+
if (!isAnimatedWebp(input)) return null;
|
|
388
|
+
return await decodeAnimatedWebpFallback(input, {
|
|
389
|
+
ffmpegPath,
|
|
390
|
+
webpmuxPath: options.webpmuxPath ?? "webpmux",
|
|
391
|
+
animDumpPath: options.animDumpPath ?? "anim_dump",
|
|
392
|
+
maxFrames,
|
|
393
|
+
maxOutputBytes,
|
|
394
|
+
timeoutMs,
|
|
395
|
+
fpsCap,
|
|
396
|
+
signal: options.signal,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|