ai-cli 0.0.12 → 0.1.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 +33 -34
- package/src/cli.test.ts +95 -0
- package/src/commands/completions.ts +296 -0
- package/src/commands/image.ts +132 -0
- package/src/commands/models.ts +117 -0
- package/src/commands/text.ts +113 -0
- package/src/commands/video.ts +109 -0
- package/src/index.ts +30 -0
- package/src/lib/color.ts +5 -0
- package/src/lib/h264-wasm.ts +164 -0
- package/src/lib/h264.test.ts +48 -0
- package/src/lib/jobs.ts +192 -0
- package/src/lib/kitty.ts +55 -0
- package/src/lib/models.test.ts +197 -0
- package/src/lib/models.ts +163 -0
- package/src/lib/mp4.test.ts +231 -0
- package/src/lib/mp4.ts +560 -0
- package/src/lib/openh264.d.mts +28 -0
- package/src/lib/openh264.mjs +423 -0
- package/src/lib/openh264.wasm +0 -0
- package/src/lib/openh264.wasm.d.ts +2 -0
- package/src/lib/output.ts +97 -0
- package/src/lib/p-map.test.ts +63 -0
- package/src/lib/p-map.ts +30 -0
- package/src/lib/parse.test.ts +114 -0
- package/src/lib/parse.ts +44 -0
- package/src/lib/png.test.ts +104 -0
- package/src/lib/png.ts +90 -0
- package/src/lib/progress.ts +214 -0
- package/src/lib/shimmer.test.ts +39 -0
- package/src/lib/shimmer.ts +42 -0
- package/src/lib/stdin.ts +31 -0
- package/README.md +0 -298
- package/dist/ai.mjs +0 -627
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
import { fetchGatewayModels, type ModelEntry } from "../lib/models.js";
|
|
4
|
+
|
|
5
|
+
function groupByProvider(models: ModelEntry[]): Map<string, ModelEntry[]> {
|
|
6
|
+
const groups = new Map<string, ModelEntry[]>();
|
|
7
|
+
for (const m of models) {
|
|
8
|
+
const slash = m.id.indexOf("/");
|
|
9
|
+
const provider = slash !== -1 ? m.id.slice(0, slash) : "other";
|
|
10
|
+
if (!groups.has(provider)) groups.set(provider, []);
|
|
11
|
+
groups.get(provider)!.push(m);
|
|
12
|
+
}
|
|
13
|
+
return new Map(
|
|
14
|
+
[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function modelName(id: string): string {
|
|
19
|
+
const slash = id.indexOf("/");
|
|
20
|
+
return slash !== -1 ? id.slice(slash + 1) : id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerModelsCommand(program: Command) {
|
|
24
|
+
program
|
|
25
|
+
.command("models")
|
|
26
|
+
.description("List available models from AI Gateway")
|
|
27
|
+
.option("--type <type>", "Filter by type: text, image, video")
|
|
28
|
+
.option("--provider <name>", "Filter by provider (e.g. openai, google)")
|
|
29
|
+
.option("--json", "Output as JSON (includes descriptions)")
|
|
30
|
+
.action(
|
|
31
|
+
async (opts: { type?: string; provider?: string; json?: boolean }) => {
|
|
32
|
+
const validTypes = ["text", "image", "video"];
|
|
33
|
+
const filterType = opts.type?.toLowerCase();
|
|
34
|
+
if (filterType && !validTypes.includes(filterType)) {
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`Error: --type must be one of: ${validTypes.join(", ")} (got "${opts.type}")\n`
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const filterProvider = opts.provider?.toLowerCase();
|
|
41
|
+
|
|
42
|
+
const gatewayModels = await fetchGatewayModels();
|
|
43
|
+
|
|
44
|
+
const filterGrouped = (grouped: Map<string, ModelEntry[]>) => {
|
|
45
|
+
if (!filterProvider) return grouped;
|
|
46
|
+
const filtered = new Map<string, ModelEntry[]>();
|
|
47
|
+
for (const [provider, models] of grouped) {
|
|
48
|
+
if (provider.toLowerCase() === filterProvider) {
|
|
49
|
+
filtered.set(provider, models);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return filtered;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (opts.json) {
|
|
56
|
+
const output: Record<string, unknown> = {};
|
|
57
|
+
const jsonMapper = (m: ModelEntry) => ({
|
|
58
|
+
id: m.id,
|
|
59
|
+
...(m.name ? { name: m.name } : {}),
|
|
60
|
+
...(m.description ? { description: m.description } : {}),
|
|
61
|
+
});
|
|
62
|
+
if (!filterType || filterType === "text") {
|
|
63
|
+
output.text = Object.fromEntries(
|
|
64
|
+
[...filterGrouped(groupByProvider(gatewayModels.text))].map(
|
|
65
|
+
([provider, models]) => [provider, models.map(jsonMapper)]
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!filterType || filterType === "image") {
|
|
70
|
+
output.image = Object.fromEntries(
|
|
71
|
+
[...filterGrouped(groupByProvider(gatewayModels.image))].map(
|
|
72
|
+
([provider, models]) => [provider, models.map(jsonMapper)]
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!filterType || filterType === "video") {
|
|
77
|
+
output.video = Object.fromEntries(
|
|
78
|
+
[...filterGrouped(groupByProvider(gatewayModels.video))].map(
|
|
79
|
+
([provider, models]) => [provider, models.map(jsonMapper)]
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
process.stdout.write(JSON.stringify(output, null, 2) + "\n");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sections: { title: string; entries: ModelEntry[] }[] = [];
|
|
88
|
+
if (!filterType || filterType === "text")
|
|
89
|
+
sections.push({ title: "Text", entries: gatewayModels.text });
|
|
90
|
+
if (!filterType || filterType === "image")
|
|
91
|
+
sections.push({ title: "Image", entries: gatewayModels.image });
|
|
92
|
+
if (!filterType || filterType === "video")
|
|
93
|
+
sections.push({ title: "Video", entries: gatewayModels.video });
|
|
94
|
+
|
|
95
|
+
let totalCount = 0;
|
|
96
|
+
for (const section of sections) {
|
|
97
|
+
const grouped = filterGrouped(groupByProvider(section.entries));
|
|
98
|
+
const count = [...grouped.values()].reduce((s, m) => s + m.length, 0);
|
|
99
|
+
if (count === 0) continue;
|
|
100
|
+
totalCount += count;
|
|
101
|
+
process.stdout.write(`\n${section.title} models (${count}):\n`);
|
|
102
|
+
for (const [provider, models] of grouped) {
|
|
103
|
+
process.stdout.write(`\n ${provider}\n`);
|
|
104
|
+
for (const m of models) {
|
|
105
|
+
process.stdout.write(` ${modelName(m.id)}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (totalCount === 0) {
|
|
111
|
+
process.stderr.write("No models found matching filters\n");
|
|
112
|
+
} else {
|
|
113
|
+
process.stdout.write("\n");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { generateText, gateway } from "ai";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
import { buildJobs, runJobs } from "../lib/jobs.js";
|
|
5
|
+
import { resolveModels } from "../lib/models.js";
|
|
6
|
+
import type { OutputFormat } from "../lib/output.js";
|
|
7
|
+
import { parsePositiveInt, parseTemperature } from "../lib/parse.js";
|
|
8
|
+
import { readStdin, stdinAsText } from "../lib/stdin.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
12
|
+
|
|
13
|
+
interface TextOptions {
|
|
14
|
+
model?: string;
|
|
15
|
+
output?: string;
|
|
16
|
+
format?: string;
|
|
17
|
+
system?: string;
|
|
18
|
+
maxTokens?: string;
|
|
19
|
+
temperature?: string;
|
|
20
|
+
count?: string;
|
|
21
|
+
concurrency?: string;
|
|
22
|
+
quiet?: boolean;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveFormat(fmt?: string): OutputFormat {
|
|
27
|
+
if (!fmt || fmt === "md") return "md";
|
|
28
|
+
if (fmt === "txt") return "txt";
|
|
29
|
+
throw new Error(`--format must be one of: md, txt (got "${fmt}")`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerTextCommand(program: Command) {
|
|
33
|
+
program
|
|
34
|
+
.command("text")
|
|
35
|
+
.description("Generate text from a prompt")
|
|
36
|
+
.argument("[prompt]", "The prompt to generate text from")
|
|
37
|
+
.option(
|
|
38
|
+
"-m, --model <model>",
|
|
39
|
+
"Model ID (creator/model-name), comma-separated for multi-model"
|
|
40
|
+
)
|
|
41
|
+
.option("-o, --output <path>", "Output file path or directory")
|
|
42
|
+
.option("-f, --format <fmt>", "Output format: md, txt (default: md)")
|
|
43
|
+
.option("-n, --count <n>", "Number of generations (default: 1)")
|
|
44
|
+
.option(
|
|
45
|
+
"-p, --concurrency <n>",
|
|
46
|
+
`Max parallel generations (default: ${DEFAULT_CONCURRENCY})`
|
|
47
|
+
)
|
|
48
|
+
.option("-s, --system <prompt>", "System prompt")
|
|
49
|
+
.option("--max-tokens <n>", "Maximum tokens to generate")
|
|
50
|
+
.option("-t, --temperature <n>", "Temperature (0-2)")
|
|
51
|
+
.option("-q, --quiet", "Suppress progress output")
|
|
52
|
+
.option("--json", "Output metadata as JSON")
|
|
53
|
+
.action(async (rawPrompt: string | undefined, opts: TextOptions) => {
|
|
54
|
+
const prompt = rawPrompt?.trim() || undefined;
|
|
55
|
+
const stdin = await readStdin();
|
|
56
|
+
if (!prompt && !stdin) {
|
|
57
|
+
process.stderr.write(
|
|
58
|
+
"Error: prompt is required (provide as argument or pipe via stdin)\n"
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
let fullPrompt: string;
|
|
63
|
+
if (stdin && prompt) {
|
|
64
|
+
fullPrompt = `${stdinAsText(stdin)}\n\n---\n\n${prompt}`;
|
|
65
|
+
} else if (stdin) {
|
|
66
|
+
fullPrompt = stdinAsText(stdin);
|
|
67
|
+
} else {
|
|
68
|
+
fullPrompt = prompt!;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const format = resolveFormat(opts.format);
|
|
72
|
+
const models = resolveModels("text", opts.model);
|
|
73
|
+
const countPerModel = opts.count
|
|
74
|
+
? parsePositiveInt(opts.count, "count")
|
|
75
|
+
: 1;
|
|
76
|
+
const maxTokens = opts.maxTokens
|
|
77
|
+
? parsePositiveInt(opts.maxTokens, "max-tokens")
|
|
78
|
+
: undefined;
|
|
79
|
+
const temperature = opts.temperature
|
|
80
|
+
? parseTemperature(opts.temperature)
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
const jobs = buildJobs(models, countPerModel);
|
|
84
|
+
|
|
85
|
+
const { total, failed } = await runJobs(
|
|
86
|
+
jobs,
|
|
87
|
+
async (modelId) => {
|
|
88
|
+
const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
|
|
89
|
+
const result = await generateText({
|
|
90
|
+
model: gateway(modelId),
|
|
91
|
+
prompt: fullPrompt,
|
|
92
|
+
system: opts.system,
|
|
93
|
+
maxOutputTokens: maxTokens,
|
|
94
|
+
temperature,
|
|
95
|
+
abortSignal: abort,
|
|
96
|
+
});
|
|
97
|
+
return result.text;
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
noun: "text",
|
|
101
|
+
format,
|
|
102
|
+
outputPath: opts.output,
|
|
103
|
+
quiet: opts.quiet,
|
|
104
|
+
json: opts.json,
|
|
105
|
+
concurrency: opts.concurrency
|
|
106
|
+
? parsePositiveInt(opts.concurrency, "concurrency")
|
|
107
|
+
: DEFAULT_CONCURRENCY,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
if (failed === total) process.exit(1);
|
|
111
|
+
if (failed > 0) process.exit(2);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { experimental_generateVideo as generateVideo, gateway } from "ai";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
import { buildJobs, runJobs } from "../lib/jobs.js";
|
|
5
|
+
import { resolveModels } from "../lib/models.js";
|
|
6
|
+
import {
|
|
7
|
+
parsePositiveInt,
|
|
8
|
+
parseAspectRatio,
|
|
9
|
+
parseNonNegativeFloat,
|
|
10
|
+
} from "../lib/parse.js";
|
|
11
|
+
import { readStdin } from "../lib/stdin.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
15
|
+
|
|
16
|
+
interface VideoOptions {
|
|
17
|
+
model?: string;
|
|
18
|
+
output?: string;
|
|
19
|
+
count?: string;
|
|
20
|
+
aspectRatio?: string;
|
|
21
|
+
duration?: string;
|
|
22
|
+
quiet?: boolean;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
concurrency?: string;
|
|
25
|
+
preview?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerVideoCommand(program: Command) {
|
|
29
|
+
program
|
|
30
|
+
.command("video")
|
|
31
|
+
.description("Generate a video from a prompt")
|
|
32
|
+
.argument("[prompt]", "The prompt to generate a video from")
|
|
33
|
+
.option(
|
|
34
|
+
"-m, --model <model>",
|
|
35
|
+
"Model ID (creator/model-name), comma-separated for multi-model"
|
|
36
|
+
)
|
|
37
|
+
.option("-o, --output <path>", "Output file path or directory")
|
|
38
|
+
.option("-n, --count <n>", "Number of videos per model (default: 1)")
|
|
39
|
+
.option("--aspect-ratio <W:H>", "Aspect ratio (e.g. 16:9)")
|
|
40
|
+
.option("--duration <seconds>", "Video duration in seconds")
|
|
41
|
+
.option("-q, --quiet", "Suppress progress output")
|
|
42
|
+
.option("--json", "Output metadata as JSON")
|
|
43
|
+
.option(
|
|
44
|
+
"--no-preview",
|
|
45
|
+
"Disable inline video frame preview in supported terminals"
|
|
46
|
+
)
|
|
47
|
+
.option(
|
|
48
|
+
"-p, --concurrency <n>",
|
|
49
|
+
`Max parallel generations (default: ${DEFAULT_CONCURRENCY})`
|
|
50
|
+
)
|
|
51
|
+
.action(async (rawPrompt: string | undefined, opts: VideoOptions) => {
|
|
52
|
+
const prompt = rawPrompt?.trim() || undefined;
|
|
53
|
+
const stdin = await readStdin();
|
|
54
|
+
if (!prompt && !stdin) {
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
"Error: prompt is required (provide as argument or pipe via stdin)\n"
|
|
57
|
+
);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let videoPrompt: string | { image: Uint8Array; text?: string } = prompt!;
|
|
62
|
+
if (stdin) {
|
|
63
|
+
videoPrompt = prompt
|
|
64
|
+
? { image: new Uint8Array(stdin), text: prompt }
|
|
65
|
+
: { image: new Uint8Array(stdin) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const models = resolveModels("video", opts.model);
|
|
69
|
+
const countPerModel = opts.count
|
|
70
|
+
? parsePositiveInt(opts.count, "count")
|
|
71
|
+
: 1;
|
|
72
|
+
const aspectRatio = opts.aspectRatio
|
|
73
|
+
? parseAspectRatio(opts.aspectRatio)
|
|
74
|
+
: undefined;
|
|
75
|
+
const duration = opts.duration
|
|
76
|
+
? parseNonNegativeFloat(opts.duration, "duration")
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
const jobs = buildJobs(models, countPerModel);
|
|
80
|
+
|
|
81
|
+
const { total, failed } = await runJobs(
|
|
82
|
+
jobs,
|
|
83
|
+
async (modelId) => {
|
|
84
|
+
const abort = AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
|
|
85
|
+
const result = await generateVideo({
|
|
86
|
+
model: gateway.video(modelId),
|
|
87
|
+
prompt: videoPrompt,
|
|
88
|
+
abortSignal: abort,
|
|
89
|
+
aspectRatio,
|
|
90
|
+
duration,
|
|
91
|
+
});
|
|
92
|
+
return Buffer.from(result.video.uint8Array);
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
noun: "video",
|
|
96
|
+
format: "video",
|
|
97
|
+
outputPath: opts.output,
|
|
98
|
+
quiet: opts.quiet,
|
|
99
|
+
json: opts.json,
|
|
100
|
+
display: opts.preview,
|
|
101
|
+
concurrency: opts.concurrency
|
|
102
|
+
? parsePositiveInt(opts.concurrency, "concurrency")
|
|
103
|
+
: DEFAULT_CONCURRENCY,
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
if (failed === total) process.exit(1);
|
|
107
|
+
if (failed > 0) process.exit(2);
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
import pkg from "../package.json";
|
|
5
|
+
import { registerCompletionsCommand } from "./commands/completions.js";
|
|
6
|
+
import { registerImageCommand } from "./commands/image.js";
|
|
7
|
+
import { registerModelsCommand } from "./commands/models.js";
|
|
8
|
+
import { registerTextCommand } from "./commands/text.js";
|
|
9
|
+
import { registerVideoCommand } from "./commands/video.js";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("ai")
|
|
15
|
+
.description(
|
|
16
|
+
"A tiny, agent-native CLI for generating images, video and text with dead-simple commands, stdin support and predictable artifact outputs"
|
|
17
|
+
)
|
|
18
|
+
.version(pkg.version);
|
|
19
|
+
|
|
20
|
+
registerTextCommand(program);
|
|
21
|
+
registerImageCommand(program);
|
|
22
|
+
registerVideoCommand(program);
|
|
23
|
+
registerModelsCommand(program);
|
|
24
|
+
registerCompletionsCommand(program);
|
|
25
|
+
|
|
26
|
+
program.parseAsync(process.argv).catch((err: unknown) => {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
28
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
package/src/lib/color.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export interface DecodedFrame {
|
|
2
|
+
yuv: Uint8Array;
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface OpenH264Module {
|
|
8
|
+
_decoder_init(): number;
|
|
9
|
+
_decoder_feed(ptr: number, len: number): number;
|
|
10
|
+
_decoder_flush(): number;
|
|
11
|
+
_decoder_destroy(): void;
|
|
12
|
+
_get_has_frame(): number;
|
|
13
|
+
_get_width(): number;
|
|
14
|
+
_get_height(): number;
|
|
15
|
+
_get_y_stride(): number;
|
|
16
|
+
_get_uv_stride(): number;
|
|
17
|
+
_get_y_ptr(): number;
|
|
18
|
+
_get_u_ptr(): number;
|
|
19
|
+
_get_v_ptr(): number;
|
|
20
|
+
_malloc(size: number): number;
|
|
21
|
+
_free(ptr: number): void;
|
|
22
|
+
HEAPU8: Uint8Array;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const START_CODE = new Uint8Array([0x00, 0x00, 0x00, 0x01]);
|
|
26
|
+
|
|
27
|
+
function buildAnnexB(
|
|
28
|
+
sps: Uint8Array,
|
|
29
|
+
pps: Uint8Array,
|
|
30
|
+
idr: Uint8Array
|
|
31
|
+
): Uint8Array {
|
|
32
|
+
const len = START_CODE.length * 3 + sps.length + pps.length + idr.length;
|
|
33
|
+
const buf = new Uint8Array(len);
|
|
34
|
+
let off = 0;
|
|
35
|
+
buf.set(START_CODE, off);
|
|
36
|
+
off += START_CODE.length;
|
|
37
|
+
buf.set(sps, off);
|
|
38
|
+
off += sps.length;
|
|
39
|
+
buf.set(START_CODE, off);
|
|
40
|
+
off += START_CODE.length;
|
|
41
|
+
buf.set(pps, off);
|
|
42
|
+
off += pps.length;
|
|
43
|
+
buf.set(START_CODE, off);
|
|
44
|
+
off += START_CODE.length;
|
|
45
|
+
buf.set(idr, off);
|
|
46
|
+
return buf;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractPlanarYUV(
|
|
50
|
+
mod: OpenH264Module,
|
|
51
|
+
width: number,
|
|
52
|
+
height: number
|
|
53
|
+
): Uint8Array {
|
|
54
|
+
const yStride = mod._get_y_stride();
|
|
55
|
+
const uvStride = mod._get_uv_stride();
|
|
56
|
+
const yPtr = mod._get_y_ptr();
|
|
57
|
+
const uPtr = mod._get_u_ptr();
|
|
58
|
+
const vPtr = mod._get_v_ptr();
|
|
59
|
+
|
|
60
|
+
const chromaW = width >> 1;
|
|
61
|
+
const chromaH = height >> 1;
|
|
62
|
+
const ySize = width * height;
|
|
63
|
+
const cSize = chromaW * chromaH;
|
|
64
|
+
const out = new Uint8Array(ySize + cSize * 2);
|
|
65
|
+
|
|
66
|
+
const heap = mod.HEAPU8;
|
|
67
|
+
|
|
68
|
+
for (let y = 0; y < height; y++) {
|
|
69
|
+
out.set(
|
|
70
|
+
heap.subarray(yPtr + y * yStride, yPtr + y * yStride + width),
|
|
71
|
+
y * width
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const cbOff = ySize;
|
|
76
|
+
for (let y = 0; y < chromaH; y++) {
|
|
77
|
+
out.set(
|
|
78
|
+
heap.subarray(uPtr + y * uvStride, uPtr + y * uvStride + chromaW),
|
|
79
|
+
cbOff + y * chromaW
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const crOff = cbOff + cSize;
|
|
84
|
+
for (let y = 0; y < chromaH; y++) {
|
|
85
|
+
out.set(
|
|
86
|
+
heap.subarray(vPtr + y * uvStride, vPtr + y * uvStride + chromaW),
|
|
87
|
+
crOff + y * chromaW
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let modulePromise: Promise<OpenH264Module> | null = null;
|
|
95
|
+
|
|
96
|
+
function getModule(): Promise<OpenH264Module> {
|
|
97
|
+
if (!modulePromise) {
|
|
98
|
+
modulePromise = (async () => {
|
|
99
|
+
const { readFileSync } = await import("fs");
|
|
100
|
+
const wasmPath: string = (await import("./openh264.wasm")).default;
|
|
101
|
+
const wasmBinary = readFileSync(wasmPath);
|
|
102
|
+
const factory = (await import("./openh264.mjs")).default;
|
|
103
|
+
return factory({
|
|
104
|
+
wasmBinary,
|
|
105
|
+
print: () => {},
|
|
106
|
+
printErr: () => {},
|
|
107
|
+
}) as Promise<OpenH264Module>;
|
|
108
|
+
})();
|
|
109
|
+
}
|
|
110
|
+
return modulePromise;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function decodeIDR(
|
|
114
|
+
sps: Uint8Array,
|
|
115
|
+
pps: Uint8Array,
|
|
116
|
+
sliceData: Uint8Array
|
|
117
|
+
): Promise<DecodedFrame | null> {
|
|
118
|
+
if (!sps.length || !pps.length || !sliceData.length) return null;
|
|
119
|
+
|
|
120
|
+
const mod = await getModule();
|
|
121
|
+
const annexB = buildAnnexB(sps, pps, sliceData);
|
|
122
|
+
|
|
123
|
+
if (mod._decoder_init() !== 0) return null;
|
|
124
|
+
|
|
125
|
+
let ptr = 0;
|
|
126
|
+
try {
|
|
127
|
+
ptr = mod._malloc(annexB.length);
|
|
128
|
+
if (!ptr) {
|
|
129
|
+
mod._decoder_destroy();
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
mod.HEAPU8.set(annexB, ptr);
|
|
133
|
+
|
|
134
|
+
mod._decoder_feed(ptr, annexB.length);
|
|
135
|
+
|
|
136
|
+
if (!mod._get_has_frame()) {
|
|
137
|
+
mod._decoder_feed(0, 0);
|
|
138
|
+
}
|
|
139
|
+
if (!mod._get_has_frame()) {
|
|
140
|
+
mod._decoder_flush();
|
|
141
|
+
}
|
|
142
|
+
if (!mod._get_has_frame()) {
|
|
143
|
+
mod._free(ptr);
|
|
144
|
+
mod._decoder_destroy();
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const width = mod._get_width();
|
|
149
|
+
const height = mod._get_height();
|
|
150
|
+
const yuv = extractPlanarYUV(mod, width, height);
|
|
151
|
+
|
|
152
|
+
mod._free(ptr);
|
|
153
|
+
mod._decoder_destroy();
|
|
154
|
+
return { yuv, width, height };
|
|
155
|
+
} catch {
|
|
156
|
+
if (ptr) mod._free(ptr);
|
|
157
|
+
try {
|
|
158
|
+
mod._decoder_destroy();
|
|
159
|
+
} catch {
|
|
160
|
+
/* best-effort */
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { decodeIDR } from "./h264-wasm.js";
|
|
4
|
+
|
|
5
|
+
describe("decodeIDR (openh264 WASM)", () => {
|
|
6
|
+
test("returns null for empty inputs", async () => {
|
|
7
|
+
expect(
|
|
8
|
+
await decodeIDR(new Uint8Array(0), new Uint8Array(0), new Uint8Array(0))
|
|
9
|
+
).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("returns null for truncated SPS", async () => {
|
|
13
|
+
const sps = new Uint8Array([0x67, 0x42]);
|
|
14
|
+
const pps = new Uint8Array([0x68, 0xce, 0x38, 0x80]);
|
|
15
|
+
const slice = new Uint8Array([0x65, 0x88, 0x80, 0x40]);
|
|
16
|
+
expect(await decodeIDR(sps, pps, slice)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("gracefully handles malformed data without crashing", async () => {
|
|
20
|
+
const sps = new Uint8Array([0x67, 0x42, 0x00, 0x0a, 0xe9, 0x40, 0x40]);
|
|
21
|
+
const pps = new Uint8Array([0x68, 0xce, 0x38, 0x80]);
|
|
22
|
+
const slice = new Uint8Array(100);
|
|
23
|
+
slice[0] = 0x65;
|
|
24
|
+
for (let i = 1; i < 100; i++) slice[i] = (i * 37) & 0xff;
|
|
25
|
+
|
|
26
|
+
const result = await decodeIDR(sps, pps, slice);
|
|
27
|
+
expect(
|
|
28
|
+
result === null || (result && result.yuv instanceof Uint8Array)
|
|
29
|
+
).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns DecodedFrame with valid YUV for well-formed data", async () => {
|
|
33
|
+
const sps = new Uint8Array([
|
|
34
|
+
0x67, 0x42, 0x00, 0x0a, 0xe9, 0x40, 0x40, 0x04, 0x00, 0x00, 0x00, 0x04,
|
|
35
|
+
0x00, 0x00, 0x00, 0xc8, 0x40,
|
|
36
|
+
]);
|
|
37
|
+
const pps = new Uint8Array([0x68, 0xce, 0x38, 0x80]);
|
|
38
|
+
const slice = new Uint8Array(200);
|
|
39
|
+
slice[0] = 0x65;
|
|
40
|
+
for (let i = 1; i < 200; i++) slice[i] = (i * 37) & 0xff;
|
|
41
|
+
|
|
42
|
+
const result = await decodeIDR(sps, pps, slice);
|
|
43
|
+
// May return null for synthetic data, but should not throw
|
|
44
|
+
expect(
|
|
45
|
+
result === null || (result && result.width > 0 && result.height > 0)
|
|
46
|
+
).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
});
|