@larkup/tool-video-intelligence 0.2.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/.env.example +150 -0
- package/LICENSE +176 -0
- package/README.md +281 -0
- package/compose.gpu.yaml +14 -0
- package/compose.yaml +71 -0
- package/dist/agent.d.ts +131 -0
- package/dist/agent.js +2087 -0
- package/dist/brief.d.ts +2 -0
- package/dist/brief.js +37 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +139 -0
- package/dist/contracts.d.ts +331 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +391 -0
- package/dist/runtime.d.ts +96 -0
- package/dist/runtime.js +592 -0
- package/dist/ui.d.ts +82 -0
- package/dist/ui.js +87 -0
- package/package.json +84 -0
- package/runtime/Dockerfile +119 -0
- package/runtime/app/__init__.py +3 -0
- package/runtime/app/__main__.py +19 -0
- package/runtime/app/api/__init__.py +0 -0
- package/runtime/app/api/deps.py +69 -0
- package/runtime/app/api/v1.py +166 -0
- package/runtime/app/config.py +78 -0
- package/runtime/app/db/__init__.py +0 -0
- package/runtime/app/db/schemas.py +162 -0
- package/runtime/app/db/store.py +466 -0
- package/runtime/app/main.py +27 -0
- package/runtime/app/model_configuration.py +157 -0
- package/runtime/app/services/__init__.py +0 -0
- package/runtime/app/services/brain.py +2221 -0
- package/runtime/app/services/embedding.py +473 -0
- package/runtime/app/services/jobs.py +237 -0
- package/runtime/app/services/motion.py +66 -0
- package/runtime/app/services/pipeline.py +1911 -0
- package/runtime/app/services/scene.py +161 -0
- package/runtime/app/services/storage.py +44 -0
- package/runtime/app/services/transcription.py +667 -0
- package/runtime/app/services/vision.py +1441 -0
- package/runtime/app/utils/__init__.py +0 -0
- package/runtime/app/utils/timing.py +99 -0
- package/runtime/app/worker.py +20 -0
- package/runtime/pyproject.toml +56 -0
- package/runtime/requirements-cpu.txt +15 -0
- package/runtime/requirements-smoke.txt +7 -0
- package/runtime/requirements.txt +14 -0
- package/runtime/uv.lock +3637 -0
- package/scripts/grant-cloud-credits.sh +43 -0
- package/scripts/runtime.mjs +156 -0
- package/scripts/validate-indexing.mjs +168 -0
- package/tool.manifest.json +617 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
echo "Usage: $0 <user-id> <monthly-source-minutes> [plan]" >&2
|
|
6
|
+
echo "Requires LARKUP_VIDEO_INTELLIGENCE_CLOUD_ENDPOINT and LARKUP_VIDEO_ADMIN_TOKEN." >&2
|
|
7
|
+
exit 64
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
USER_ID="${1:-}"
|
|
11
|
+
SOURCE_MINUTES="${2:-}"
|
|
12
|
+
PLAN="${3:-support-grant}"
|
|
13
|
+
|
|
14
|
+
[[ -n "$USER_ID" && -n "$SOURCE_MINUTES" ]] || usage
|
|
15
|
+
[[ "$USER_ID" =~ ^[A-Za-z0-9._-]{32,128}$ ]] || {
|
|
16
|
+
echo "User ID must be the generated ID shown in Installed Tools." >&2
|
|
17
|
+
exit 64
|
|
18
|
+
}
|
|
19
|
+
[[ "$SOURCE_MINUTES" =~ ^[0-9]+([.][0-9]+)?$ ]] || {
|
|
20
|
+
echo "Monthly source minutes must be a non-negative number." >&2
|
|
21
|
+
exit 64
|
|
22
|
+
}
|
|
23
|
+
[[ "$PLAN" =~ ^[A-Za-z0-9._-]{1,80}$ ]] || {
|
|
24
|
+
echo "Plan may contain only letters, numbers, dots, underscores, and hyphens." >&2
|
|
25
|
+
exit 64
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ENDPOINT="${LARKUP_VIDEO_INTELLIGENCE_CLOUD_ENDPOINT:-}"
|
|
29
|
+
ADMIN_TOKEN="${LARKUP_VIDEO_ADMIN_TOKEN:-}"
|
|
30
|
+
[[ -n "$ENDPOINT" && -n "$ADMIN_TOKEN" ]] || {
|
|
31
|
+
echo "Set LARKUP_VIDEO_INTELLIGENCE_CLOUD_ENDPOINT and LARKUP_VIDEO_ADMIN_TOKEN first." >&2
|
|
32
|
+
exit 64
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ENDPOINT="${ENDPOINT%/}"
|
|
36
|
+
PAYLOAD=$(printf '{"sourceMinutesPerMonth":%s,"plan":"%s"}' "$SOURCE_MINUTES" "$PLAN")
|
|
37
|
+
|
|
38
|
+
curl --fail-with-body --silent --show-error \
|
|
39
|
+
--request POST "$ENDPOINT/v1/admin/devices/$USER_ID/entitlement" \
|
|
40
|
+
--header 'Content-Type: application/json' \
|
|
41
|
+
--header "X-Larkup-Admin-Token: $ADMIN_TOKEN" \
|
|
42
|
+
--data "$PAYLOAD"
|
|
43
|
+
echo
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const [command = 'status', ...args] = process.argv.slice(2);
|
|
9
|
+
const envPath = path.join(packageDir, '.env');
|
|
10
|
+
const exampleEnvPath = path.join(packageDir, '.env.example');
|
|
11
|
+
|
|
12
|
+
function knownKeys() {
|
|
13
|
+
return new Set(
|
|
14
|
+
readFileSync(exampleEnvPath, 'utf8')
|
|
15
|
+
.split(/\r?\n/)
|
|
16
|
+
.map((line) => line.match(/^([A-Z][A-Z0-9_]*)=/)?.[1])
|
|
17
|
+
.filter(Boolean),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEnvValue(key) {
|
|
22
|
+
if (!existsSync(envPath)) return '';
|
|
23
|
+
const line = readFileSync(envPath, 'utf8')
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.find((entry) => entry.startsWith(`${key}=`));
|
|
26
|
+
if (!line) return '';
|
|
27
|
+
const value = line.slice(key.length + 1).trim();
|
|
28
|
+
if (!value.startsWith('"')) return value;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(value);
|
|
31
|
+
} catch {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function initializeEnv() {
|
|
37
|
+
if (existsSync(envPath)) return;
|
|
38
|
+
writeFileSync(envPath, readFileSync(exampleEnvPath, 'utf8'));
|
|
39
|
+
console.log('Created .env from .env.example. Add credentials with `config set KEY VALUE`.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runtimeEnvironment() {
|
|
43
|
+
if (!existsSync(envPath)) return process.env;
|
|
44
|
+
return {
|
|
45
|
+
...process.env,
|
|
46
|
+
...Object.fromEntries(
|
|
47
|
+
readFileSync(envPath, 'utf8')
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.flatMap((line) => {
|
|
50
|
+
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
|
51
|
+
if (!match) return [];
|
|
52
|
+
const [, key, raw] = match;
|
|
53
|
+
try {
|
|
54
|
+
return [[key, raw.startsWith('"') ? JSON.parse(raw) : raw]];
|
|
55
|
+
} catch {
|
|
56
|
+
return [[key, raw]];
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nativeRuntimeEnvironment() {
|
|
64
|
+
const dataDir = path.join(process.cwd(), '.larkup', 'video-intelligence');
|
|
65
|
+
return {
|
|
66
|
+
...runtimeEnvironment(),
|
|
67
|
+
LARKUP_VIDEO_RUNTIME_KIND: 'local-process',
|
|
68
|
+
LARKUP_VIDEO_HOST: '127.0.0.1',
|
|
69
|
+
LARKUP_VIDEO_DATA_DIR: path.join(dataDir, 'data'),
|
|
70
|
+
LARKUP_VIDEO_MODEL_DIR: path.join(dataDir, 'models'),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function configure() {
|
|
75
|
+
const [operation = 'path', key, ...valueParts] = args;
|
|
76
|
+
if (operation === 'path') {
|
|
77
|
+
console.log(envPath);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (operation === 'init') {
|
|
81
|
+
initializeEnv();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!key || !knownKeys().has(key)) {
|
|
85
|
+
console.error('Use a key declared in .env.example. Run `config path` to find the file.');
|
|
86
|
+
process.exitCode = 2;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (operation === 'get') {
|
|
90
|
+
console.log(readEnvValue(key));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
operation !== 'set' ||
|
|
95
|
+
valueParts.length === 0 ||
|
|
96
|
+
valueParts.some((part) => /[\r\n]/.test(part))
|
|
97
|
+
) {
|
|
98
|
+
console.error('Usage: larkup-video-intelligence config <init|path|get KEY|set KEY VALUE>');
|
|
99
|
+
process.exitCode = 2;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
initializeEnv();
|
|
103
|
+
const value = valueParts.join(' ');
|
|
104
|
+
const line = `${key}=${JSON.stringify(value)}`;
|
|
105
|
+
const contents = readFileSync(envPath, 'utf8');
|
|
106
|
+
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
|
107
|
+
writeFileSync(
|
|
108
|
+
envPath,
|
|
109
|
+
pattern.test(contents) ? contents.replace(pattern, line) : `${contents}\n${line}\n`,
|
|
110
|
+
);
|
|
111
|
+
console.log(`Updated ${key} in .env.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (command === 'config') {
|
|
115
|
+
configure();
|
|
116
|
+
process.exit(process.exitCode ?? 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (command === 'start') initializeEnv();
|
|
120
|
+
const accelerator = readEnvValue('LARKUP_VIDEO_ACCELERATOR').toLowerCase();
|
|
121
|
+
const gpu = args.includes('--gpu') || accelerator === 'gpu' || accelerator === 'cuda';
|
|
122
|
+
const compose = ['compose', '-f', path.join(packageDir, 'compose.yaml')];
|
|
123
|
+
if (gpu) compose.push('-f', path.join(packageDir, 'compose.gpu.yaml'));
|
|
124
|
+
|
|
125
|
+
const actions = {
|
|
126
|
+
start: [...compose, 'up', '-d', '--build', '--wait'],
|
|
127
|
+
stop: [...compose, 'down'],
|
|
128
|
+
status: [...compose, 'ps'],
|
|
129
|
+
logs: [...compose, 'logs', '-f', '--tail=200'],
|
|
130
|
+
pull: [...compose, 'pull'],
|
|
131
|
+
native: [
|
|
132
|
+
'run',
|
|
133
|
+
'--directory',
|
|
134
|
+
path.join(packageDir, 'runtime'),
|
|
135
|
+
'--extra',
|
|
136
|
+
'cpu',
|
|
137
|
+
'larkup-video-runtime',
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (!(command in actions)) {
|
|
142
|
+
console.error(
|
|
143
|
+
'Usage: larkup-video-intelligence <start|stop|status|logs|pull|native> [--gpu]\\n' +
|
|
144
|
+
' larkup-video-intelligence config <init|path|get KEY|set KEY VALUE>',
|
|
145
|
+
);
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
const result = spawnSync(command === 'native' ? 'uv' : 'docker', actions[command], {
|
|
149
|
+
stdio: 'inherit',
|
|
150
|
+
env: command === 'native' ? nativeRuntimeEnvironment() : process.env,
|
|
151
|
+
});
|
|
152
|
+
if (result.error) {
|
|
153
|
+
console.error(`Could not run Docker: ${result.error.message}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
process.exit(result.status ?? 1);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { openAsBlob } from 'node:fs';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const { values } = parseArgs({
|
|
7
|
+
options: {
|
|
8
|
+
endpoint: { type: 'string' },
|
|
9
|
+
runtime: { type: 'string', default: 'cloud' },
|
|
10
|
+
file: { type: 'string' },
|
|
11
|
+
output: { type: 'string' },
|
|
12
|
+
duration: { type: 'string' },
|
|
13
|
+
mode: { type: 'string', default: 'balanced' },
|
|
14
|
+
language: { type: 'string', default: 'auto' },
|
|
15
|
+
key: { type: 'string' },
|
|
16
|
+
question: { type: 'string' },
|
|
17
|
+
'range-start': { type: 'string' },
|
|
18
|
+
'range-end': { type: 'string' },
|
|
19
|
+
'max-frames': { type: 'string' },
|
|
20
|
+
continuous: { type: 'boolean', default: false },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!values.endpoint || !values.file || !values.output || !values.duration) {
|
|
25
|
+
throw new Error('--endpoint, --file, --output, and --duration are required');
|
|
26
|
+
}
|
|
27
|
+
if (!['fast', 'balanced', 'thorough'].includes(values.mode)) {
|
|
28
|
+
throw new Error('--mode must be fast, balanced, or thorough');
|
|
29
|
+
}
|
|
30
|
+
if (!values.language.trim()) {
|
|
31
|
+
throw new Error('--language must be auto or a non-empty language code');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const hasRange = values['range-start'] !== undefined || values['range-end'] !== undefined;
|
|
35
|
+
const rangeStart = Number(values['range-start']);
|
|
36
|
+
const rangeEnd = Number(values['range-end']);
|
|
37
|
+
if (
|
|
38
|
+
hasRange &&
|
|
39
|
+
(!Number.isFinite(rangeStart) ||
|
|
40
|
+
!Number.isFinite(rangeEnd) ||
|
|
41
|
+
rangeStart < 0 ||
|
|
42
|
+
rangeEnd <= rangeStart)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error('--range-start and --range-end must form a finite increasing range');
|
|
45
|
+
}
|
|
46
|
+
const maxFrames = values['max-frames'] === undefined ? undefined : Number(values['max-frames']);
|
|
47
|
+
if (maxFrames !== undefined && (!Number.isInteger(maxFrames) || maxFrames < 1 || maxFrames > 24)) {
|
|
48
|
+
throw new Error('--max-frames must be an integer between 1 and 24');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const endpoint = values.endpoint.replace(/\/$/, '');
|
|
52
|
+
let apiKey = values.key || '';
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
|
|
55
|
+
async function request(path, init = {}, anonymous = false) {
|
|
56
|
+
const headers = new Headers(init.headers);
|
|
57
|
+
if (!(init.body instanceof FormData)) headers.set('Content-Type', 'application/json');
|
|
58
|
+
if (apiKey && !anonymous) headers.set('Authorization', `Bearer ${apiKey}`);
|
|
59
|
+
const response = await fetch(`${endpoint}${path}`, { ...init, headers });
|
|
60
|
+
const body = await response.json().catch(() => ({}));
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(body.detail || body.error || `${path} returned HTTP ${response.status}`);
|
|
63
|
+
}
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const health = await request('/v1/health');
|
|
68
|
+
if (health.status !== 'ok') throw new Error('runtime health check failed');
|
|
69
|
+
|
|
70
|
+
if (values.runtime === 'cloud' && !apiKey) {
|
|
71
|
+
const provisioned = await request(
|
|
72
|
+
'/v1/device-keys',
|
|
73
|
+
{ method: 'POST', body: JSON.stringify({ installationId: randomUUID() }) },
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
apiKey = provisioned.apiKey;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const blob = await openAsBlob(values.file, { type: 'video/mp4' });
|
|
80
|
+
let upload;
|
|
81
|
+
if (values.runtime === 'cloud') {
|
|
82
|
+
upload = await request('/v1/uploads', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
fileName: 'validation-video.mp4',
|
|
86
|
+
contentType: 'video/mp4',
|
|
87
|
+
sizeBytes: blob.size,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
const uploaded = await fetch(upload.uploadUrl, {
|
|
91
|
+
method: 'PUT',
|
|
92
|
+
headers: upload.uploadHeaders,
|
|
93
|
+
body: blob,
|
|
94
|
+
});
|
|
95
|
+
if (!uploaded.ok) throw new Error(`source upload returned HTTP ${uploaded.status}`);
|
|
96
|
+
} else {
|
|
97
|
+
const form = new FormData();
|
|
98
|
+
form.append('file', blob, 'validation-video.mp4');
|
|
99
|
+
upload = await request('/v1/uploads', { method: 'POST', body: form });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let job = await request('/v1/jobs', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
source: { uploadId: upload.uploadId, durationSecs: Number(values.duration) },
|
|
106
|
+
brief: {
|
|
107
|
+
indexingMode: values.mode,
|
|
108
|
+
language: values.language.trim(),
|
|
109
|
+
goal: values.question
|
|
110
|
+
? `Answer this question from direct timestamped source evidence: ${values.question}`
|
|
111
|
+
: 'Build a clean timestamped account of the entities, visible and spoken state history, key events, and overall source context. Use only source evidence and do not assume a content genre.',
|
|
112
|
+
expectedQuestions: values.question
|
|
113
|
+
? [values.question]
|
|
114
|
+
: [
|
|
115
|
+
'Who or what participated?',
|
|
116
|
+
'How did the visible or spoken state change over time?',
|
|
117
|
+
'What were the key events and the final context?',
|
|
118
|
+
],
|
|
119
|
+
...(hasRange
|
|
120
|
+
? {
|
|
121
|
+
importantRanges: [
|
|
122
|
+
{ startSecs: rangeStart, endSecs: rangeEnd, note: 'bounded validation' },
|
|
123
|
+
],
|
|
124
|
+
requireSemanticVision: true,
|
|
125
|
+
skipVideoEmbeddings: true,
|
|
126
|
+
continuousSequence: values.continuous,
|
|
127
|
+
...(maxFrames === undefined ? {} : { maxFrames }),
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
let lastProgress = '';
|
|
135
|
+
while (job.status === 'queued' || job.status === 'running') {
|
|
136
|
+
const progressKey = JSON.stringify(job.progress);
|
|
137
|
+
if (progressKey !== lastProgress) {
|
|
138
|
+
const elapsedSecs = Math.round((Date.now() - startedAt) / 1000);
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`${JSON.stringify({ elapsedSecs, status: job.status, ...job.progress })}\n`,
|
|
141
|
+
);
|
|
142
|
+
lastProgress = progressKey;
|
|
143
|
+
}
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
145
|
+
job = await request(`/v1/jobs/${encodeURIComponent(job.id)}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (job.status !== 'completed') throw new Error(job.error || `job ended as ${job.status}`);
|
|
149
|
+
if (!job.result && job.resultUrl) {
|
|
150
|
+
const response = await fetch(job.resultUrl);
|
|
151
|
+
if (!response.ok) throw new Error(`result download returned HTTP ${response.status}`);
|
|
152
|
+
job.result = await response.json();
|
|
153
|
+
}
|
|
154
|
+
if (!job.result) throw new Error('completed job did not contain a result');
|
|
155
|
+
|
|
156
|
+
const elapsedSecs = (Date.now() - startedAt) / 1_000;
|
|
157
|
+
await writeFile(
|
|
158
|
+
values.output,
|
|
159
|
+
JSON.stringify({ runtime: values.runtime, elapsedSecs, jobId: job.id, result: job.result }, null, 2),
|
|
160
|
+
'utf8',
|
|
161
|
+
);
|
|
162
|
+
if (values.runtime === 'cloud') {
|
|
163
|
+
await request(`/v1/jobs/${encodeURIComponent(job.id)}/result/ack`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
body: '{}',
|
|
166
|
+
}).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(`${JSON.stringify({ status: 'completed', elapsedSecs, output: values.output })}\n`);
|