ai-cli 0.2.0 → 0.2.1
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/README.md +1 -1
- package/dist/index.js +30027 -0
- package/{src/lib/openh264.wasm → dist/openh264-d6yed0d8.wasm} +0 -0
- package/package.json +9 -5
- package/src/cli.test.ts +0 -70
- package/src/commands/image.ts +0 -189
- package/src/commands/models.ts +0 -106
- package/src/commands/text.ts +0 -118
- package/src/commands/video.ts +0 -114
- package/src/index.ts +0 -28
- package/src/lib/color.ts +0 -5
- package/src/lib/h264-wasm.ts +0 -164
- package/src/lib/h264.test.ts +0 -48
- package/src/lib/jobs.ts +0 -192
- package/src/lib/kitty.ts +0 -55
- package/src/lib/models.test.ts +0 -307
- package/src/lib/models.ts +0 -173
- package/src/lib/mp4.test.ts +0 -231
- package/src/lib/mp4.ts +0 -560
- package/src/lib/openh264.d.mts +0 -28
- package/src/lib/openh264.mjs +0 -423
- package/src/lib/openh264.wasm.d.ts +0 -2
- package/src/lib/output.ts +0 -97
- package/src/lib/p-map.test.ts +0 -63
- package/src/lib/p-map.ts +0 -30
- package/src/lib/parse.test.ts +0 -114
- package/src/lib/parse.ts +0 -44
- package/src/lib/png.test.ts +0 -104
- package/src/lib/png.ts +0 -90
- package/src/lib/progress.ts +0 -214
- package/src/lib/shimmer.test.ts +0 -39
- package/src/lib/shimmer.ts +0 -42
- package/src/lib/stdin.ts +0 -31
package/src/lib/models.test.ts
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
resolveModels,
|
|
5
|
-
fetchGatewayModels,
|
|
6
|
-
resetGatewayCache,
|
|
7
|
-
} from "./models.js";
|
|
8
|
-
|
|
9
|
-
const originalFetch = globalThis.fetch;
|
|
10
|
-
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
globalThis.fetch = originalFetch;
|
|
13
|
-
resetGatewayCache();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
function mockGateway(models: Record<string, unknown>[]) {
|
|
17
|
-
globalThis.fetch = mock(() =>
|
|
18
|
-
Promise.resolve(
|
|
19
|
-
new Response(JSON.stringify({ data: models }), { status: 200 })
|
|
20
|
-
)
|
|
21
|
-
) as unknown as typeof fetch;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function mockGatewayError() {
|
|
25
|
-
globalThis.fetch = mock(() =>
|
|
26
|
-
Promise.reject(new Error("network error"))
|
|
27
|
-
) as unknown as typeof fetch;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("resolveModels", () => {
|
|
31
|
-
test("returns default when no user model", () => {
|
|
32
|
-
expect(resolveModels("text")[0]).toContain("/");
|
|
33
|
-
expect(resolveModels("image")[0]).toContain("/");
|
|
34
|
-
expect(resolveModels("video")[0]).toContain("/");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("returns fully-qualified model as-is", () => {
|
|
38
|
-
expect(resolveModels("text", "openai/gpt-4")).toEqual(["openai/gpt-4"]);
|
|
39
|
-
expect(resolveModels("image", "openai/gpt-image-1")).toEqual([
|
|
40
|
-
"openai/gpt-image-1",
|
|
41
|
-
]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("expands short names when knownModels provided", () => {
|
|
45
|
-
const known = [{ id: "openai/gpt-image-1" }, { id: "bfl/flux-2-pro" }];
|
|
46
|
-
expect(resolveModels("image", "gpt-image-1", known)).toEqual([
|
|
47
|
-
"openai/gpt-image-1",
|
|
48
|
-
]);
|
|
49
|
-
expect(resolveModels("image", "flux-2-pro", known)).toEqual([
|
|
50
|
-
"bfl/flux-2-pro",
|
|
51
|
-
]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("returns unknown short names as-is when no knownModels", () => {
|
|
55
|
-
expect(resolveModels("text", "my-model")).toEqual(["my-model"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("returns unknown short names as-is when not in knownModels", () => {
|
|
59
|
-
const known = [{ id: "openai/gpt-5" }];
|
|
60
|
-
expect(resolveModels("text", "nonexistent", known)).toEqual([
|
|
61
|
-
"nonexistent",
|
|
62
|
-
]);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("resolveModels multi", () => {
|
|
67
|
-
test("returns default when no user model", () => {
|
|
68
|
-
const result = resolveModels("text");
|
|
69
|
-
expect(result).toHaveLength(1);
|
|
70
|
-
expect(result[0]).toContain("/");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("splits comma-separated models", () => {
|
|
74
|
-
const result = resolveModels("image", "openai/gpt-image-1,bfl/flux-2-pro");
|
|
75
|
-
expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("trims whitespace around model names", () => {
|
|
79
|
-
const result = resolveModels(
|
|
80
|
-
"image",
|
|
81
|
-
"openai/gpt-image-1 , bfl/flux-2-pro"
|
|
82
|
-
);
|
|
83
|
-
expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("expands short names in comma list", () => {
|
|
87
|
-
const known = [{ id: "openai/gpt-image-1" }, { id: "bfl/flux-2-pro" }];
|
|
88
|
-
const result = resolveModels("image", "gpt-image-1,flux-2-pro", known);
|
|
89
|
-
expect(result).toEqual(["openai/gpt-image-1", "bfl/flux-2-pro"]);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("filters empty segments from trailing comma", () => {
|
|
93
|
-
const result = resolveModels("image", "openai/gpt-image-1,");
|
|
94
|
-
expect(result).toEqual(["openai/gpt-image-1"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("falls back to default when all segments are empty", () => {
|
|
98
|
-
const result = resolveModels("image", ",,,");
|
|
99
|
-
expect(result).toHaveLength(1);
|
|
100
|
-
expect(result[0]).toContain("/");
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("fetchGatewayModels", () => {
|
|
105
|
-
test("partitions models by type with enriched fields", async () => {
|
|
106
|
-
mockGateway([
|
|
107
|
-
{
|
|
108
|
-
id: "openai/gpt-5",
|
|
109
|
-
name: "GPT 5",
|
|
110
|
-
owned_by: "openai",
|
|
111
|
-
type: "language",
|
|
112
|
-
tags: [],
|
|
113
|
-
pricing: { input: "0.000003", output: "0.000015" },
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
id: "openai/gpt-image-2",
|
|
117
|
-
name: "GPT Image 2",
|
|
118
|
-
description: "Image gen",
|
|
119
|
-
owned_by: "openai",
|
|
120
|
-
type: "image",
|
|
121
|
-
tags: ["image-generation"],
|
|
122
|
-
pricing: { image: "0.02" },
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
id: "google/veo-3.0",
|
|
126
|
-
name: "Veo 3",
|
|
127
|
-
owned_by: "google",
|
|
128
|
-
type: "video",
|
|
129
|
-
tags: [],
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
id: "openai/text-embedding-3",
|
|
133
|
-
name: "Embedding",
|
|
134
|
-
owned_by: "openai",
|
|
135
|
-
type: "embedding",
|
|
136
|
-
tags: [],
|
|
137
|
-
},
|
|
138
|
-
]);
|
|
139
|
-
|
|
140
|
-
const result = await fetchGatewayModels();
|
|
141
|
-
|
|
142
|
-
expect(result.text).toHaveLength(1);
|
|
143
|
-
expect(result.text[0].id).toBe("openai/gpt-5");
|
|
144
|
-
expect(result.text[0].creator).toBe("openai");
|
|
145
|
-
expect(result.text[0].capabilities).toEqual(["text"]);
|
|
146
|
-
expect(result.text[0].pricing).toEqual({
|
|
147
|
-
input: "0.000003",
|
|
148
|
-
output: "0.000015",
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
expect(result.image).toHaveLength(1);
|
|
152
|
-
expect(result.image[0].id).toBe("openai/gpt-image-2");
|
|
153
|
-
expect(result.image[0].creator).toBe("openai");
|
|
154
|
-
expect(result.image[0].capabilities).toEqual(["image"]);
|
|
155
|
-
expect(result.image[0].description).toBe("Image gen");
|
|
156
|
-
expect(result.image[0].pricing).toEqual({ image: "0.02" });
|
|
157
|
-
|
|
158
|
-
expect(result.video).toHaveLength(1);
|
|
159
|
-
expect(result.video[0].id).toBe("google/veo-3.0");
|
|
160
|
-
expect(result.video[0].creator).toBe("google");
|
|
161
|
-
expect(result.video[0].capabilities).toEqual(["video"]);
|
|
162
|
-
|
|
163
|
-
// embedding type is excluded
|
|
164
|
-
expect(result.all).toHaveLength(3);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("language models with image-generation tag appear in both text and image", async () => {
|
|
168
|
-
mockGateway([
|
|
169
|
-
{
|
|
170
|
-
id: "google/gemini-2.5-flash-image",
|
|
171
|
-
name: "Gemini Flash Image",
|
|
172
|
-
owned_by: "google",
|
|
173
|
-
type: "language",
|
|
174
|
-
tags: ["image-generation"],
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
id: "openai/gpt-image-2",
|
|
178
|
-
name: "GPT Image 2",
|
|
179
|
-
owned_by: "openai",
|
|
180
|
-
type: "image",
|
|
181
|
-
tags: ["image-generation"],
|
|
182
|
-
},
|
|
183
|
-
]);
|
|
184
|
-
|
|
185
|
-
const result = await fetchGatewayModels();
|
|
186
|
-
|
|
187
|
-
expect(result.text.map((m) => m.id)).toContain(
|
|
188
|
-
"google/gemini-2.5-flash-image"
|
|
189
|
-
);
|
|
190
|
-
expect(result.image.map((m) => m.id)).toContain(
|
|
191
|
-
"google/gemini-2.5-flash-image"
|
|
192
|
-
);
|
|
193
|
-
expect(result.image.map((m) => m.id)).toContain("openai/gpt-image-2");
|
|
194
|
-
|
|
195
|
-
const gemini = result.all.find(
|
|
196
|
-
(m) => m.id === "google/gemini-2.5-flash-image"
|
|
197
|
-
)!;
|
|
198
|
-
expect(gemini.capabilities).toEqual(["text", "image"]);
|
|
199
|
-
|
|
200
|
-
expect(
|
|
201
|
-
result.languageImageModelIds.has("google/gemini-2.5-flash-image")
|
|
202
|
-
).toBe(true);
|
|
203
|
-
expect(result.languageImageModelIds.has("openai/gpt-image-2")).toBe(false);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("language models without image-generation tag stay in text only", async () => {
|
|
207
|
-
mockGateway([
|
|
208
|
-
{
|
|
209
|
-
id: "openai/gpt-5",
|
|
210
|
-
name: "GPT 5",
|
|
211
|
-
owned_by: "openai",
|
|
212
|
-
type: "language",
|
|
213
|
-
tags: ["tool-use"],
|
|
214
|
-
},
|
|
215
|
-
]);
|
|
216
|
-
|
|
217
|
-
const result = await fetchGatewayModels();
|
|
218
|
-
|
|
219
|
-
expect(result.text).toHaveLength(1);
|
|
220
|
-
expect(result.image).toHaveLength(0);
|
|
221
|
-
expect(result.languageImageModelIds.size).toBe(0);
|
|
222
|
-
expect(result.all[0].capabilities).toEqual(["text"]);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("returns empty lists on gateway error", async () => {
|
|
226
|
-
mockGatewayError();
|
|
227
|
-
|
|
228
|
-
const result = await fetchGatewayModels();
|
|
229
|
-
|
|
230
|
-
expect(result.text).toHaveLength(0);
|
|
231
|
-
expect(result.image).toHaveLength(0);
|
|
232
|
-
expect(result.video).toHaveLength(0);
|
|
233
|
-
expect(result.all).toHaveLength(0);
|
|
234
|
-
expect(result.languageImageModelIds.size).toBe(0);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("returns empty lists on non-200 response", async () => {
|
|
238
|
-
globalThis.fetch = mock(() =>
|
|
239
|
-
Promise.resolve(new Response("Not Found", { status: 404 }))
|
|
240
|
-
) as unknown as typeof fetch;
|
|
241
|
-
|
|
242
|
-
const result = await fetchGatewayModels();
|
|
243
|
-
|
|
244
|
-
expect(result.text).toHaveLength(0);
|
|
245
|
-
expect(result.image).toHaveLength(0);
|
|
246
|
-
expect(result.video).toHaveLength(0);
|
|
247
|
-
expect(result.all).toHaveLength(0);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test("caches result across multiple calls", async () => {
|
|
251
|
-
const fetchMock = mock(() =>
|
|
252
|
-
Promise.resolve(
|
|
253
|
-
new Response(
|
|
254
|
-
JSON.stringify({
|
|
255
|
-
data: [
|
|
256
|
-
{
|
|
257
|
-
id: "openai/gpt-5",
|
|
258
|
-
name: "GPT 5",
|
|
259
|
-
owned_by: "openai",
|
|
260
|
-
type: "language",
|
|
261
|
-
tags: [],
|
|
262
|
-
},
|
|
263
|
-
],
|
|
264
|
-
}),
|
|
265
|
-
{ status: 200 }
|
|
266
|
-
)
|
|
267
|
-
)
|
|
268
|
-
);
|
|
269
|
-
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
270
|
-
|
|
271
|
-
const r1 = await fetchGatewayModels();
|
|
272
|
-
const r2 = await fetchGatewayModels();
|
|
273
|
-
|
|
274
|
-
expect(r1).toBe(r2);
|
|
275
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test("pricing is omitted when all pricing fields are empty", async () => {
|
|
279
|
-
mockGateway([
|
|
280
|
-
{
|
|
281
|
-
id: "openai/gpt-5",
|
|
282
|
-
name: "GPT 5",
|
|
283
|
-
owned_by: "openai",
|
|
284
|
-
type: "language",
|
|
285
|
-
tags: [],
|
|
286
|
-
pricing: {},
|
|
287
|
-
},
|
|
288
|
-
]);
|
|
289
|
-
|
|
290
|
-
const result = await fetchGatewayModels();
|
|
291
|
-
expect(result.text[0].pricing).toBeUndefined();
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
test("falls back to parsing creator from id when owned_by is absent", async () => {
|
|
295
|
-
mockGateway([
|
|
296
|
-
{
|
|
297
|
-
id: "openai/gpt-5",
|
|
298
|
-
name: "GPT 5",
|
|
299
|
-
type: "language",
|
|
300
|
-
tags: [],
|
|
301
|
-
},
|
|
302
|
-
]);
|
|
303
|
-
|
|
304
|
-
const result = await fetchGatewayModels();
|
|
305
|
-
expect(result.text[0].creator).toBe("openai");
|
|
306
|
-
});
|
|
307
|
-
});
|
package/src/lib/models.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
export type Modality = "text" | "image" | "video";
|
|
2
|
-
|
|
3
|
-
const DEFAULTS: Record<Modality, string> = {
|
|
4
|
-
text: process.env.AI_CLI_TEXT_MODEL ?? "openai/gpt-5.5",
|
|
5
|
-
image: process.env.AI_CLI_IMAGE_MODEL ?? "openai/gpt-image-2",
|
|
6
|
-
video: process.env.AI_CLI_VIDEO_MODEL ?? "bytedance/seedance-2.0",
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models";
|
|
10
|
-
const GATEWAY_TIMEOUT_MS = 5_000;
|
|
11
|
-
|
|
12
|
-
export interface ModelPricing {
|
|
13
|
-
input?: string;
|
|
14
|
-
output?: string;
|
|
15
|
-
image?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ModelEntry {
|
|
19
|
-
id: string;
|
|
20
|
-
name?: string;
|
|
21
|
-
description?: string;
|
|
22
|
-
creator: string;
|
|
23
|
-
capabilities: Modality[];
|
|
24
|
-
pricing?: ModelPricing;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface GatewayModels {
|
|
28
|
-
text: ModelEntry[];
|
|
29
|
-
image: ModelEntry[];
|
|
30
|
-
video: ModelEntry[];
|
|
31
|
-
all: ModelEntry[];
|
|
32
|
-
languageImageModelIds: Set<string>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface RawGatewayModel {
|
|
36
|
-
id: string;
|
|
37
|
-
name?: string;
|
|
38
|
-
description?: string;
|
|
39
|
-
owned_by?: string;
|
|
40
|
-
type?: string;
|
|
41
|
-
tags?: string[];
|
|
42
|
-
pricing?: {
|
|
43
|
-
input?: string;
|
|
44
|
-
output?: string;
|
|
45
|
-
image?: string;
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let cached: Promise<GatewayModels> | null = null;
|
|
50
|
-
|
|
51
|
-
export function fetchGatewayModels(): Promise<GatewayModels> {
|
|
52
|
-
if (!cached) {
|
|
53
|
-
cached = doFetch().catch((err) => {
|
|
54
|
-
cached = null;
|
|
55
|
-
throw err;
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
return cached;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function resetGatewayCache(): void {
|
|
62
|
-
cached = null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function doFetch(): Promise<GatewayModels> {
|
|
66
|
-
const result: GatewayModels = {
|
|
67
|
-
text: [],
|
|
68
|
-
image: [],
|
|
69
|
-
video: [],
|
|
70
|
-
all: [],
|
|
71
|
-
languageImageModelIds: new Set(),
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const res = await fetch(GATEWAY_MODELS_URL, {
|
|
76
|
-
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
77
|
-
});
|
|
78
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
79
|
-
const json = (await res.json()) as { data?: RawGatewayModel[] };
|
|
80
|
-
const models = json.data ?? [];
|
|
81
|
-
|
|
82
|
-
const entryMap = new Map<string, ModelEntry>();
|
|
83
|
-
|
|
84
|
-
for (const m of models) {
|
|
85
|
-
const tags = m.tags ?? [];
|
|
86
|
-
const isImageGen = tags.includes("image-generation");
|
|
87
|
-
const capabilities: Modality[] = [];
|
|
88
|
-
|
|
89
|
-
switch (m.type) {
|
|
90
|
-
case "language":
|
|
91
|
-
capabilities.push("text");
|
|
92
|
-
if (isImageGen) capabilities.push("image");
|
|
93
|
-
break;
|
|
94
|
-
case "image":
|
|
95
|
-
capabilities.push("image");
|
|
96
|
-
break;
|
|
97
|
-
case "video":
|
|
98
|
-
capabilities.push("video");
|
|
99
|
-
break;
|
|
100
|
-
default:
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const creator =
|
|
105
|
-
m.owned_by ??
|
|
106
|
-
(m.id.slice(0, Math.max(0, m.id.indexOf("/"))) || "other");
|
|
107
|
-
|
|
108
|
-
const pricing: ModelPricing | undefined =
|
|
109
|
-
m.pricing?.input || m.pricing?.output || m.pricing?.image
|
|
110
|
-
? {
|
|
111
|
-
...(m.pricing.input ? { input: m.pricing.input } : {}),
|
|
112
|
-
...(m.pricing.output ? { output: m.pricing.output } : {}),
|
|
113
|
-
...(m.pricing.image ? { image: m.pricing.image } : {}),
|
|
114
|
-
}
|
|
115
|
-
: undefined;
|
|
116
|
-
|
|
117
|
-
const entry: ModelEntry = {
|
|
118
|
-
id: m.id,
|
|
119
|
-
name: m.name,
|
|
120
|
-
description: m.description,
|
|
121
|
-
creator,
|
|
122
|
-
capabilities,
|
|
123
|
-
pricing,
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
entryMap.set(m.id, entry);
|
|
127
|
-
|
|
128
|
-
if (capabilities.includes("text")) result.text.push(entry);
|
|
129
|
-
if (capabilities.includes("image")) result.image.push(entry);
|
|
130
|
-
if (capabilities.includes("video")) result.video.push(entry);
|
|
131
|
-
|
|
132
|
-
if (m.type === "language" && isImageGen) {
|
|
133
|
-
result.languageImageModelIds.add(m.id);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
result.all = [...entryMap.values()];
|
|
138
|
-
} catch {
|
|
139
|
-
cached = null;
|
|
140
|
-
process.stderr.write("Warning: could not fetch models from AI Gateway\n");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return result;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function resolveModels(
|
|
147
|
-
modality: Modality,
|
|
148
|
-
userModel?: string,
|
|
149
|
-
knownModels?: Pick<ModelEntry, "id">[]
|
|
150
|
-
): string[] {
|
|
151
|
-
if (!userModel) return [DEFAULTS[modality]];
|
|
152
|
-
const models = userModel
|
|
153
|
-
.split(",")
|
|
154
|
-
.map((m) => m.trim())
|
|
155
|
-
.filter(Boolean)
|
|
156
|
-
.map((m) => expandModelId(m, knownModels));
|
|
157
|
-
return models.length > 0 ? models : [DEFAULTS[modality]];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function expandModelId(
|
|
161
|
-
input: string,
|
|
162
|
-
knownModels?: Pick<ModelEntry, "id">[]
|
|
163
|
-
): string {
|
|
164
|
-
if (input.includes("/")) return input;
|
|
165
|
-
if (!knownModels) return input;
|
|
166
|
-
|
|
167
|
-
for (const m of knownModels) {
|
|
168
|
-
const name = m.id.slice(m.id.indexOf("/") + 1);
|
|
169
|
-
if (name === input) return m.id;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return input;
|
|
173
|
-
}
|
package/src/lib/mp4.test.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { extractKeyframe } from "./mp4.js";
|
|
4
|
-
|
|
5
|
-
describe("extractKeyframe", () => {
|
|
6
|
-
test("returns null for empty buffer", () => {
|
|
7
|
-
expect(extractKeyframe(new Uint8Array(0))).toBeNull();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test("returns null for buffer too small", () => {
|
|
11
|
-
expect(extractKeyframe(new Uint8Array(4))).toBeNull();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test("returns null when no moov box found", () => {
|
|
15
|
-
// Valid-looking box header but wrong type
|
|
16
|
-
const buf = new Uint8Array(16);
|
|
17
|
-
const view = new DataView(buf.buffer);
|
|
18
|
-
view.setUint32(0, 16);
|
|
19
|
-
buf[4] = 0x66;
|
|
20
|
-
buf[5] = 0x74;
|
|
21
|
-
buf[6] = 0x79;
|
|
22
|
-
buf[7] = 0x70; // ftyp
|
|
23
|
-
expect(extractKeyframe(buf)).toBeNull();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("returns null for minimal moov with no video track", () => {
|
|
27
|
-
// Construct a minimal moov box with no trak inside
|
|
28
|
-
const moovPayload = new Uint8Array(0);
|
|
29
|
-
const moovSize = 8 + moovPayload.length;
|
|
30
|
-
const buf = new Uint8Array(moovSize);
|
|
31
|
-
const view = new DataView(buf.buffer);
|
|
32
|
-
view.setUint32(0, moovSize);
|
|
33
|
-
buf[4] = 0x6d;
|
|
34
|
-
buf[5] = 0x6f;
|
|
35
|
-
buf[6] = 0x6f;
|
|
36
|
-
buf[7] = 0x76; // moov
|
|
37
|
-
expect(extractKeyframe(buf)).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("builds a minimal MP4 structure and extracts keyframe data", () => {
|
|
41
|
-
// This test creates a valid-ish MP4 structure to exercise the parsing path.
|
|
42
|
-
// The actual H.264 data is minimal/dummy, so we just verify the parser
|
|
43
|
-
// successfully extracts SPS, PPS, and slice data.
|
|
44
|
-
const mp4 = buildMinimalMP4();
|
|
45
|
-
const result = extractKeyframe(mp4);
|
|
46
|
-
|
|
47
|
-
// If the MP4 was well-formed enough, we should get non-null result
|
|
48
|
-
if (result) {
|
|
49
|
-
expect(result.sps.length).toBeGreaterThan(0);
|
|
50
|
-
expect(result.pps.length).toBeGreaterThan(0);
|
|
51
|
-
expect(result.sliceData.length).toBeGreaterThan(0);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
function writeBox(type: string, payload: Uint8Array): Uint8Array {
|
|
57
|
-
const size = 8 + payload.length;
|
|
58
|
-
const buf = new Uint8Array(size);
|
|
59
|
-
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
60
|
-
view.setUint32(0, size);
|
|
61
|
-
for (let i = 0; i < 4; i++) buf[4 + i] = type.charCodeAt(i);
|
|
62
|
-
buf.set(payload, 8);
|
|
63
|
-
return buf;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function concatArrays(...arrs: Uint8Array[]): Uint8Array {
|
|
67
|
-
const total = arrs.reduce((s, a) => s + a.length, 0);
|
|
68
|
-
const result = new Uint8Array(total);
|
|
69
|
-
let off = 0;
|
|
70
|
-
for (const a of arrs) {
|
|
71
|
-
result.set(a, off);
|
|
72
|
-
off += a.length;
|
|
73
|
-
}
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function buildMinimalMP4(): Uint8Array {
|
|
78
|
-
// SPS NAL unit (minimal baseline profile, 16x16 resolution)
|
|
79
|
-
const sps = new Uint8Array([
|
|
80
|
-
0x67, 0x42, 0x00, 0x0a, 0xe9, 0x40, 0x40, 0x04, 0x00, 0x00, 0x00, 0x04,
|
|
81
|
-
0x00, 0x00, 0x00, 0xc8, 0x40,
|
|
82
|
-
]);
|
|
83
|
-
const pps = new Uint8Array([0x68, 0xce, 0x38, 0x80]);
|
|
84
|
-
|
|
85
|
-
// avcC box
|
|
86
|
-
const avcCPayload = new Uint8Array([
|
|
87
|
-
1, // version
|
|
88
|
-
0x42, // profile
|
|
89
|
-
0x00, // compat
|
|
90
|
-
0x0a, // level
|
|
91
|
-
0xff, // nal length size = 4
|
|
92
|
-
0xe1, // num SPS = 1
|
|
93
|
-
// SPS length + data
|
|
94
|
-
(sps.length >> 8) & 0xff,
|
|
95
|
-
sps.length & 0xff,
|
|
96
|
-
...sps,
|
|
97
|
-
1, // num PPS = 1
|
|
98
|
-
(pps.length >> 8) & 0xff,
|
|
99
|
-
pps.length & 0xff,
|
|
100
|
-
...pps,
|
|
101
|
-
]);
|
|
102
|
-
const avcC = writeBox("avcC", avcCPayload);
|
|
103
|
-
|
|
104
|
-
// avc1 box: 78 bytes fixed header + avcC child
|
|
105
|
-
const avc1Fixed = new Uint8Array(78);
|
|
106
|
-
// data_ref_index at offset 6 = 1
|
|
107
|
-
avc1Fixed[6] = 0;
|
|
108
|
-
avc1Fixed[7] = 1;
|
|
109
|
-
// width at offset 24
|
|
110
|
-
avc1Fixed[24] = 0;
|
|
111
|
-
avc1Fixed[25] = 16;
|
|
112
|
-
// height at offset 26
|
|
113
|
-
avc1Fixed[26] = 0;
|
|
114
|
-
avc1Fixed[27] = 16;
|
|
115
|
-
const avc1Payload = concatArrays(avc1Fixed, avcC);
|
|
116
|
-
const avc1 = writeBox("avc1", avc1Payload);
|
|
117
|
-
|
|
118
|
-
// stsd box: version(4) + entry_count(4) + avc1
|
|
119
|
-
const stsdInner = new Uint8Array(8);
|
|
120
|
-
const stsdView = new DataView(stsdInner.buffer);
|
|
121
|
-
stsdView.setUint32(4, 1); // entry count
|
|
122
|
-
const stsd = writeBox("stsd", concatArrays(stsdInner, avc1));
|
|
123
|
-
|
|
124
|
-
// stsz (1 sample, size 10)
|
|
125
|
-
const stszPayload = new Uint8Array(12);
|
|
126
|
-
const stszView = new DataView(stszPayload.buffer);
|
|
127
|
-
stszView.setUint32(4, 0); // sample_size = variable
|
|
128
|
-
stszView.setUint32(8, 1); // sample_count = 1
|
|
129
|
-
const stszEntry = new Uint8Array(4);
|
|
130
|
-
new DataView(stszEntry.buffer).setUint32(0, 10);
|
|
131
|
-
const stsz = writeBox("stsz", concatArrays(stszPayload, stszEntry));
|
|
132
|
-
|
|
133
|
-
// stsc (1 entry: chunk 1, 1 sample/chunk, sdi 1)
|
|
134
|
-
const stscPayload = new Uint8Array(16);
|
|
135
|
-
const stscView = new DataView(stscPayload.buffer);
|
|
136
|
-
stscView.setUint32(4, 1); // entry count
|
|
137
|
-
stscView.setUint32(8, 1); // first chunk
|
|
138
|
-
stscView.setUint32(12, 1); // samples per chunk
|
|
139
|
-
const stscExtra = new Uint8Array(4);
|
|
140
|
-
new DataView(stscExtra.buffer).setUint32(0, 1);
|
|
141
|
-
const stsc = writeBox("stsc", concatArrays(stscPayload, stscExtra));
|
|
142
|
-
|
|
143
|
-
// stco (1 chunk offset, will be filled in later)
|
|
144
|
-
const stcoPayload = new Uint8Array(8);
|
|
145
|
-
const stcoView = new DataView(stcoPayload.buffer);
|
|
146
|
-
stcoView.setUint32(4, 1); // entry count
|
|
147
|
-
// offset will be set after we know the layout
|
|
148
|
-
const stcoOffsetPos = 8; // position within stco payload for the offset value
|
|
149
|
-
const stco = writeBox("stco", concatArrays(stcoPayload, new Uint8Array(4)));
|
|
150
|
-
|
|
151
|
-
// stss (1 sync sample: sample 1)
|
|
152
|
-
const stssPayload = new Uint8Array(8);
|
|
153
|
-
const stssView = new DataView(stssPayload.buffer);
|
|
154
|
-
stssView.setUint32(4, 1); // entry count
|
|
155
|
-
const stssSample = new Uint8Array(4);
|
|
156
|
-
new DataView(stssSample.buffer).setUint32(0, 1);
|
|
157
|
-
const stss = writeBox("stss", concatArrays(stssPayload, stssSample));
|
|
158
|
-
|
|
159
|
-
const stbl = writeBox("stbl", concatArrays(stsd, stsz, stsc, stco, stss));
|
|
160
|
-
const minf = writeBox("minf", stbl);
|
|
161
|
-
|
|
162
|
-
// hdlr box (video handler)
|
|
163
|
-
const hdlrPayload = new Uint8Array(20);
|
|
164
|
-
// handler_type at offset 8..11 = "vide"
|
|
165
|
-
hdlrPayload[8] = 0x76;
|
|
166
|
-
hdlrPayload[9] = 0x69;
|
|
167
|
-
hdlrPayload[10] = 0x64;
|
|
168
|
-
hdlrPayload[11] = 0x65;
|
|
169
|
-
const hdlr = writeBox("hdlr", hdlrPayload);
|
|
170
|
-
|
|
171
|
-
const mdia = writeBox("mdia", concatArrays(hdlr, minf));
|
|
172
|
-
const trak = writeBox("trak", mdia);
|
|
173
|
-
const moov = writeBox("moov", trak);
|
|
174
|
-
|
|
175
|
-
// mdat with a dummy IDR NAL unit (length-prefixed)
|
|
176
|
-
const idrNal = new Uint8Array([0x65, 0x88, 0x80, 0x40, 0x00, 0x00]);
|
|
177
|
-
const mdatPayload = new Uint8Array(4 + idrNal.length);
|
|
178
|
-
new DataView(mdatPayload.buffer).setUint32(0, idrNal.length);
|
|
179
|
-
mdatPayload.set(idrNal, 4);
|
|
180
|
-
|
|
181
|
-
// ftyp box
|
|
182
|
-
const ftyp = writeBox(
|
|
183
|
-
"ftyp",
|
|
184
|
-
new Uint8Array([
|
|
185
|
-
0x69,
|
|
186
|
-
0x73,
|
|
187
|
-
0x6f,
|
|
188
|
-
0x6d, // brand: isom
|
|
189
|
-
0x00,
|
|
190
|
-
0x00,
|
|
191
|
-
0x02,
|
|
192
|
-
0x00, // version
|
|
193
|
-
])
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
const mdat = writeBox("mdat", mdatPayload);
|
|
197
|
-
|
|
198
|
-
// Calculate chunk offset
|
|
199
|
-
const mdatOffset = ftyp.length + moov.length + 8; // 8 for mdat header
|
|
200
|
-
const fullMp4 = concatArrays(ftyp, moov, mdat);
|
|
201
|
-
|
|
202
|
-
// Patch the stco chunk offset
|
|
203
|
-
// We need to find the stco entry in the final buffer and patch it
|
|
204
|
-
const stcoMarker = findStcoOffset(fullMp4);
|
|
205
|
-
if (stcoMarker >= 0) {
|
|
206
|
-
const view = new DataView(
|
|
207
|
-
fullMp4.buffer,
|
|
208
|
-
fullMp4.byteOffset,
|
|
209
|
-
fullMp4.byteLength
|
|
210
|
-
);
|
|
211
|
-
view.setUint32(stcoMarker, mdatOffset);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return fullMp4;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function findStcoOffset(buf: Uint8Array): number {
|
|
218
|
-
// Find the stco box and return the position of its first chunk offset entry
|
|
219
|
-
for (let i = 0; i < buf.length - 12; i++) {
|
|
220
|
-
if (
|
|
221
|
-
buf[i] === 0x73 &&
|
|
222
|
-
buf[i + 1] === 0x74 &&
|
|
223
|
-
buf[i + 2] === 0x63 &&
|
|
224
|
-
buf[i + 3] === 0x6f
|
|
225
|
-
) {
|
|
226
|
-
// Found "stco" - the chunk offset entry is at i + 4 (version/flags) + 4 (entry_count) + 4
|
|
227
|
-
return i + 4 + 8;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return -1;
|
|
231
|
-
}
|