@sireai/optimus 0.1.7 → 0.1.9
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 +2 -1
- package/dist/cli/optimus.js +145 -81
- package/dist/cli/optimus.js.map +1 -1
- package/dist/config/load-config.js +3 -0
- package/dist/config/load-config.js.map +1 -1
- package/dist/integrations/feishu/feishu-auth-config.d.ts +7 -0
- package/dist/integrations/feishu/feishu-auth-config.js +37 -0
- package/dist/integrations/feishu/feishu-auth-config.js.map +1 -0
- package/dist/integrations/feishu/feishu-auth-service.d.ts +71 -0
- package/dist/integrations/feishu/feishu-auth-service.js +399 -0
- package/dist/integrations/feishu/feishu-auth-service.js.map +1 -0
- package/dist/integrations/feishu/feishu-auth-store.d.ts +29 -0
- package/dist/integrations/feishu/feishu-auth-store.js +113 -0
- package/dist/integrations/feishu/feishu-auth-store.js.map +1 -0
- package/dist/integrations/feishu/feishu-client.d.ts +52 -0
- package/dist/integrations/feishu/feishu-client.js +217 -0
- package/dist/integrations/feishu/feishu-client.js.map +1 -0
- package/dist/integrations/feishu/feishu-doc-service.d.ts +46 -0
- package/dist/integrations/feishu/feishu-doc-service.js +281 -0
- package/dist/integrations/feishu/feishu-doc-service.js.map +1 -0
- package/dist/integrations/feishu/feishu-token-store.d.ts +17 -0
- package/dist/integrations/feishu/feishu-token-store.js +34 -0
- package/dist/integrations/feishu/feishu-token-store.js.map +1 -0
- package/dist/integrations/feishu/feishu-user-service.d.ts +14 -0
- package/dist/integrations/feishu/feishu-user-service.js +60 -0
- package/dist/integrations/feishu/feishu-user-service.js.map +1 -0
- package/dist/integrations/jira/jira-cli.js +30 -11
- package/dist/integrations/jira/jira-cli.js.map +1 -1
- package/dist/integrations/jira/jira-client.d.ts +26 -0
- package/dist/integrations/jira/jira-client.js +111 -0
- package/dist/integrations/jira/jira-client.js.map +1 -1
- package/dist/integrations/jira/jira-submit.d.ts +5 -5
- package/dist/integrations/jira/jira-submit.js +97 -66
- package/dist/integrations/jira/jira-submit.js.map +1 -1
- package/dist/problem-solving-core/codex/codex-runner.d.ts +2 -0
- package/dist/problem-solving-core/codex/codex-runner.js +65 -0
- package/dist/problem-solving-core/codex/codex-runner.js.map +1 -1
- package/dist/task-environment/delivery/feishu-analysis-doc-service.d.ts +7 -25
- package/dist/task-environment/delivery/feishu-analysis-doc-service.js +32 -337
- package/dist/task-environment/delivery/feishu-analysis-doc-service.js.map +1 -1
- package/dist/task-environment/delivery/feishu-card-renderer.js +8 -6
- package/dist/task-environment/delivery/feishu-card-renderer.js.map +1 -1
- package/dist/task-environment/delivery/sentry-feishu-card-renderer.js +6 -4
- package/dist/task-environment/delivery/sentry-feishu-card-renderer.js.map +1 -1
- package/dist/task-environment/delivery/task-publication-service.d.ts +9 -0
- package/dist/task-environment/delivery/task-publication-service.js +147 -8
- package/dist/task-environment/delivery/task-publication-service.js.map +1 -1
- package/dist/task-environment/evidence/evidence-preparation-service.d.ts +36 -0
- package/dist/task-environment/evidence/evidence-preparation-service.js +341 -0
- package/dist/task-environment/evidence/evidence-preparation-service.js.map +1 -0
- package/dist/task-environment/intake/manual-problem-intake.js +15 -0
- package/dist/task-environment/intake/manual-problem-intake.js.map +1 -1
- package/dist/task-environment/observability/execution-metrics.js +1 -1
- package/dist/task-environment/observability/execution-metrics.js.map +1 -1
- package/dist/task-environment/observability/logger.js +21 -0
- package/dist/task-environment/observability/logger.js.map +1 -1
- package/dist/task-environment/orchestration/task-orchestrator.d.ts +3 -0
- package/dist/task-environment/orchestration/task-orchestrator.js +87 -1
- package/dist/task-environment/orchestration/task-orchestrator.js.map +1 -1
- package/dist/task-environment/orchestration/triage-runner.d.ts +2 -0
- package/dist/task-environment/orchestration/triage-runner.js +27 -1
- package/dist/task-environment/orchestration/triage-runner.js.map +1 -1
- package/dist/task-environment/runtime/optimus-runtime.js +4 -3
- package/dist/task-environment/runtime/optimus-runtime.js.map +1 -1
- package/dist/task-environment/storage/sqlite-task-store.js +1 -0
- package/dist/task-environment/storage/sqlite-task-store.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/embedded-skills/shared/video-keyframe-analyzer/SKILL.md +88 -0
- package/embedded-skills/shared/video-keyframe-analyzer/references/encountered-problems.md +12 -0
- package/embedded-skills/shared/video-keyframe-analyzer/references/triage-checklist.md +48 -0
- package/embedded-skills/shared/video-keyframe-analyzer/scripts/extract-keyframes.mjs +614 -0
- package/embedded-skills/shared/{repo-inspection → video-keyframe-analyzer}/skill.json +1 -1
- package/package.json +6 -2
- package/embedded-skills/shared/repo-inspection/SKILL.md +0 -9
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { access, copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SAMPLE_FPS = 2;
|
|
7
|
+
const DEFAULT_SCENE_THRESHOLD = 0.18;
|
|
8
|
+
const DEFAULT_CONTACT_FPS = "1,0.5";
|
|
9
|
+
const DEFAULT_TILE = "4x4";
|
|
10
|
+
const DEFAULT_MIN_KEYFRAME_GAP_SECONDS = 0.8;
|
|
11
|
+
const DEFAULT_STATIC_KEEP_SECONDS = 5;
|
|
12
|
+
const DEFAULT_MAX_KEYFRAMES = 24;
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const parsed = {};
|
|
16
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
17
|
+
const token = argv[index];
|
|
18
|
+
if (!token?.startsWith("--")) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const key = token.slice(2);
|
|
22
|
+
const next = argv[index + 1];
|
|
23
|
+
if (!next || next.startsWith("--")) {
|
|
24
|
+
parsed[key] = "true";
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
parsed[key] = next;
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function usage() {
|
|
34
|
+
return [
|
|
35
|
+
"Usage:",
|
|
36
|
+
" extract-keyframes --video <path> [options]",
|
|
37
|
+
"",
|
|
38
|
+
"Options:",
|
|
39
|
+
" --output-dir <dir> Explicit output directory. Highest priority",
|
|
40
|
+
" --task-root <dir> Write to <task-root>/.optimus/artifacts/video-keyframes/<video-stem>",
|
|
41
|
+
" --artifact-name <name> Artifact subdirectory name under video-keyframes",
|
|
42
|
+
" --sample-fps <n> Uniform sampled frames FPS. Default: 2",
|
|
43
|
+
" --scene-threshold <n> ffmpeg scene threshold. Default: 0.18",
|
|
44
|
+
" --contact-fps <csv> Contact sheet FPS values. Default: 1,0.5",
|
|
45
|
+
" --tile <cols>x<rows> Contact sheet tile layout. Default: 4x4",
|
|
46
|
+
" --min-keyframe-gap <seconds> De-duplicate nearby scene frames. Default: 0.8",
|
|
47
|
+
" --static-keep-seconds <seconds> Preserve tail frame before long static periods end. Default: 5",
|
|
48
|
+
" --max-keyframes <n> Maximum selected keyframes including first/last. Default: 24"
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function run(command, args, options = {}) {
|
|
53
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
54
|
+
const child = spawn(command, args, {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
57
|
+
});
|
|
58
|
+
let stdout = "";
|
|
59
|
+
let stderr = "";
|
|
60
|
+
child.stdout.on("data", (chunk) => {
|
|
61
|
+
stdout += chunk.toString();
|
|
62
|
+
});
|
|
63
|
+
child.stderr.on("data", (chunk) => {
|
|
64
|
+
stderr += chunk.toString();
|
|
65
|
+
});
|
|
66
|
+
child.on("error", rejectPromise);
|
|
67
|
+
child.on("close", (code) => {
|
|
68
|
+
if (code !== 0) {
|
|
69
|
+
rejectPromise(new Error(`Command failed: ${command} ${args.join(" ")}\n${stderr.trim() || stdout.trim()}`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
resolvePromise({ stdout, stderr });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function ensureTool(name) {
|
|
78
|
+
try {
|
|
79
|
+
await run("which", [name]);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`Required tool not found: ${name}. Install ffmpeg so both ffmpeg and ffprobe are available.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readDuration(videoPath) {
|
|
86
|
+
const { stdout } = await run("ffprobe", [
|
|
87
|
+
"-v",
|
|
88
|
+
"error",
|
|
89
|
+
"-show_entries",
|
|
90
|
+
"format=duration",
|
|
91
|
+
"-of",
|
|
92
|
+
"default=nw=1:nk=1",
|
|
93
|
+
videoPath
|
|
94
|
+
]);
|
|
95
|
+
const duration = Number.parseFloat(stdout.trim());
|
|
96
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
97
|
+
throw new Error(`Unable to read video duration from ffprobe output: ${stdout.trim()}`);
|
|
98
|
+
}
|
|
99
|
+
return duration;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function extractFrameAt(videoPath, outputPath, timeSeconds) {
|
|
103
|
+
await run("ffmpeg", [
|
|
104
|
+
"-y",
|
|
105
|
+
"-ss",
|
|
106
|
+
formatSeconds(timeSeconds),
|
|
107
|
+
"-i",
|
|
108
|
+
videoPath,
|
|
109
|
+
"-frames:v",
|
|
110
|
+
"1",
|
|
111
|
+
"-q:v",
|
|
112
|
+
"2",
|
|
113
|
+
outputPath
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function extractSampledFrames(videoPath, framesDir, sampleFps) {
|
|
118
|
+
await rm(framesDir, { recursive: true, force: true });
|
|
119
|
+
await mkdir(framesDir, { recursive: true });
|
|
120
|
+
await run("ffmpeg", [
|
|
121
|
+
"-y",
|
|
122
|
+
"-i",
|
|
123
|
+
videoPath,
|
|
124
|
+
"-vf",
|
|
125
|
+
`fps=${sampleFps}`,
|
|
126
|
+
"-q:v",
|
|
127
|
+
"3",
|
|
128
|
+
join(framesDir, "frame-%04d.jpg")
|
|
129
|
+
]);
|
|
130
|
+
const files = await listJpegs(framesDir);
|
|
131
|
+
return files.length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function extractContactSheet(videoPath, outputPath, fps, tile) {
|
|
135
|
+
await run("ffmpeg", [
|
|
136
|
+
"-y",
|
|
137
|
+
"-i",
|
|
138
|
+
videoPath,
|
|
139
|
+
"-vf",
|
|
140
|
+
`fps=${fps},scale=180:-1,tile=${tile}`,
|
|
141
|
+
"-frames:v",
|
|
142
|
+
"1",
|
|
143
|
+
"-update",
|
|
144
|
+
"1",
|
|
145
|
+
outputPath
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function extractSceneFrames(videoPath, sceneDir, threshold, durationSeconds) {
|
|
150
|
+
await mkdir(sceneDir, { recursive: true });
|
|
151
|
+
const metadataPath = join(sceneDir, "scene-metadata.txt");
|
|
152
|
+
try {
|
|
153
|
+
await run("ffmpeg", [
|
|
154
|
+
"-y",
|
|
155
|
+
"-i",
|
|
156
|
+
videoPath,
|
|
157
|
+
"-vf",
|
|
158
|
+
`select='gt(scene,${threshold})',metadata=print:file=${metadataPath}`,
|
|
159
|
+
"-vsync",
|
|
160
|
+
"vfr",
|
|
161
|
+
"-q:v",
|
|
162
|
+
"2",
|
|
163
|
+
join(sceneDir, "scene-%04d.jpg")
|
|
164
|
+
]);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
if (!message.includes("Nothing was written") && !message.includes("No filtered frames")) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const files = await listJpegs(sceneDir);
|
|
173
|
+
const times = await readSceneTimes(metadataPath);
|
|
174
|
+
return files.map((file, index) => ({
|
|
175
|
+
sourcePath: join(sceneDir, file),
|
|
176
|
+
timeSeconds: times[index] ?? estimateSceneTime(index, files.length, durationSeconds),
|
|
177
|
+
reason: "visual_change"
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readSceneTimes(metadataPath) {
|
|
182
|
+
try {
|
|
183
|
+
const raw = await readFile(metadataPath, "utf8");
|
|
184
|
+
const times = [];
|
|
185
|
+
for (const line of raw.split(/\r?\n/u)) {
|
|
186
|
+
const match = line.match(/pts_time:([0-9.]+)/u);
|
|
187
|
+
if (match?.[1]) {
|
|
188
|
+
const value = Number.parseFloat(match[1]);
|
|
189
|
+
if (Number.isFinite(value)) {
|
|
190
|
+
times.push(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return times;
|
|
195
|
+
} catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function buildKeyframes(input) {
|
|
201
|
+
const keyframesDir = join(input.outputDir, "keyframes");
|
|
202
|
+
const sceneDir = join(input.outputDir, "scene-candidates");
|
|
203
|
+
await rm(keyframesDir, { recursive: true, force: true });
|
|
204
|
+
await rm(sceneDir, { recursive: true, force: true });
|
|
205
|
+
await mkdir(keyframesDir, { recursive: true });
|
|
206
|
+
|
|
207
|
+
const candidates = [];
|
|
208
|
+
const firstPath = join(keyframesDir, "keyframe-first-source.jpg");
|
|
209
|
+
await extractFrameAt(input.videoPath, firstPath, 0);
|
|
210
|
+
candidates.push({
|
|
211
|
+
sourcePath: firstPath,
|
|
212
|
+
timeSeconds: 0,
|
|
213
|
+
reason: "first"
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const sceneCandidates = dedupeSceneCandidates(
|
|
217
|
+
await extractSceneFrames(input.videoPath, sceneDir, input.sceneThreshold, input.durationSeconds),
|
|
218
|
+
input.minKeyframeGapSeconds
|
|
219
|
+
);
|
|
220
|
+
const staticTailCandidates = await buildStaticTailCandidates({
|
|
221
|
+
videoPath: input.videoPath,
|
|
222
|
+
outputDir: input.outputDir,
|
|
223
|
+
sceneCandidates,
|
|
224
|
+
durationSeconds: input.durationSeconds,
|
|
225
|
+
staticKeepSeconds: input.staticKeepSeconds,
|
|
226
|
+
minKeyframeGapSeconds: input.minKeyframeGapSeconds
|
|
227
|
+
});
|
|
228
|
+
candidates.push(...sceneCandidates, ...staticTailCandidates);
|
|
229
|
+
|
|
230
|
+
const lastTime = Math.max(input.durationSeconds - Math.min(0.5, input.durationSeconds / 2), 0);
|
|
231
|
+
const lastPath = join(keyframesDir, "keyframe-last-source.jpg");
|
|
232
|
+
await extractFrameAt(input.videoPath, lastPath, lastTime);
|
|
233
|
+
candidates.push({
|
|
234
|
+
sourcePath: lastPath,
|
|
235
|
+
timeSeconds: input.durationSeconds,
|
|
236
|
+
reason: "last"
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const selectedCandidates = limitCandidates(
|
|
240
|
+
sortCandidates(candidates),
|
|
241
|
+
input.maxKeyframes
|
|
242
|
+
);
|
|
243
|
+
const normalized = [];
|
|
244
|
+
for (const candidate of selectedCandidates) {
|
|
245
|
+
const sequence = normalized.length + 1;
|
|
246
|
+
const safeTime = Number.isFinite(candidate.timeSeconds) ? candidate.timeSeconds : 0;
|
|
247
|
+
const outputName = `keyframe-${String(sequence).padStart(3, "0")}-${String(Math.round(safeTime * 1000)).padStart(6, "0")}ms-${candidate.reason}.jpg`;
|
|
248
|
+
const outputPath = join(keyframesDir, outputName);
|
|
249
|
+
if (candidate.sourcePath !== outputPath) {
|
|
250
|
+
await copyFile(candidate.sourcePath, outputPath);
|
|
251
|
+
}
|
|
252
|
+
normalized.push({
|
|
253
|
+
index: sequence,
|
|
254
|
+
time_seconds: roundSeconds(safeTime),
|
|
255
|
+
reason: candidate.reason,
|
|
256
|
+
path: outputPath
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await rm(sceneDir, { recursive: true, force: true });
|
|
261
|
+
await rm(join(input.outputDir, "static-tail-candidates"), { recursive: true, force: true });
|
|
262
|
+
await rm(firstPath, { force: true });
|
|
263
|
+
await rm(lastPath, { force: true });
|
|
264
|
+
return normalized;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function buildStaticTailCandidates(input) {
|
|
268
|
+
if (input.staticKeepSeconds <= 0 || input.sceneCandidates.length === 0) {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const staticDir = join(input.outputDir, "static-tail-candidates");
|
|
273
|
+
await mkdir(staticDir, { recursive: true });
|
|
274
|
+
const candidates = [];
|
|
275
|
+
const orderedSceneTimes = input.sceneCandidates
|
|
276
|
+
.map((candidate) => candidate.timeSeconds)
|
|
277
|
+
.filter((time) => Number.isFinite(time))
|
|
278
|
+
.sort((left, right) => left - right);
|
|
279
|
+
let previousChangeTime = 0;
|
|
280
|
+
let sequence = 1;
|
|
281
|
+
for (const sceneTime of orderedSceneTimes) {
|
|
282
|
+
const gap = sceneTime - previousChangeTime;
|
|
283
|
+
if (gap >= input.staticKeepSeconds) {
|
|
284
|
+
const tailTime = Math.max(sceneTime - 0.05, previousChangeTime);
|
|
285
|
+
if (tailTime - previousChangeTime >= input.minKeyframeGapSeconds) {
|
|
286
|
+
const outputPath = join(staticDir, `static-tail-${String(sequence).padStart(4, "0")}.jpg`);
|
|
287
|
+
await extractFrameAt(input.videoPath, outputPath, tailTime);
|
|
288
|
+
candidates.push({
|
|
289
|
+
sourcePath: outputPath,
|
|
290
|
+
timeSeconds: tailTime,
|
|
291
|
+
reason: "static_tail"
|
|
292
|
+
});
|
|
293
|
+
sequence += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
previousChangeTime = sceneTime;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return candidates;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sortCandidates(candidates) {
|
|
303
|
+
return [...candidates].sort((left, right) => {
|
|
304
|
+
const leftTime = Number.isFinite(left.timeSeconds) ? left.timeSeconds : Number.POSITIVE_INFINITY;
|
|
305
|
+
const rightTime = Number.isFinite(right.timeSeconds) ? right.timeSeconds : Number.POSITIVE_INFINITY;
|
|
306
|
+
if (leftTime !== rightTime) {
|
|
307
|
+
return leftTime - rightTime;
|
|
308
|
+
}
|
|
309
|
+
return reasonPriority(left.reason) - reasonPriority(right.reason);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function limitCandidates(candidates, maxKeyframes) {
|
|
314
|
+
if (!Number.isFinite(maxKeyframes) || maxKeyframes <= 0 || candidates.length <= maxKeyframes) {
|
|
315
|
+
return candidates;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const first = candidates.find((candidate) => candidate.reason === "first") ?? candidates[0];
|
|
319
|
+
const last = [...candidates].reverse().find((candidate) => candidate.reason === "last") ?? candidates[candidates.length - 1];
|
|
320
|
+
const middle = candidates.filter((candidate) => candidate !== first && candidate !== last);
|
|
321
|
+
const allowedMiddleCount = Math.max(maxKeyframes - 2, 0);
|
|
322
|
+
const selectedMiddle = sampleEvenly(middle, allowedMiddleCount);
|
|
323
|
+
return sortCandidates([first, ...selectedMiddle, last]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sampleEvenly(items, count) {
|
|
327
|
+
if (count <= 0) {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
if (items.length <= count) {
|
|
331
|
+
return items;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const selected = [];
|
|
335
|
+
for (let index = 0; index < count; index += 1) {
|
|
336
|
+
const sourceIndex = Math.round(index * (items.length - 1) / Math.max(count - 1, 1));
|
|
337
|
+
selected.push(items[sourceIndex]);
|
|
338
|
+
}
|
|
339
|
+
return Array.from(new Set(selected));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function reasonPriority(reason) {
|
|
343
|
+
switch (reason) {
|
|
344
|
+
case "first":
|
|
345
|
+
return 0;
|
|
346
|
+
case "visual_change":
|
|
347
|
+
return 1;
|
|
348
|
+
case "static_tail":
|
|
349
|
+
return 2;
|
|
350
|
+
case "last":
|
|
351
|
+
return 3;
|
|
352
|
+
default:
|
|
353
|
+
return 9;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function dedupeSceneCandidates(candidates, minGapSeconds) {
|
|
358
|
+
const withTimes = candidates.filter((candidate) => Number.isFinite(candidate.timeSeconds));
|
|
359
|
+
if (withTimes.length === 0) {
|
|
360
|
+
return candidates;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const selected = [];
|
|
364
|
+
for (const candidate of withTimes) {
|
|
365
|
+
const previous = selected[selected.length - 1];
|
|
366
|
+
if (!previous || candidate.timeSeconds - previous.timeSeconds >= minGapSeconds) {
|
|
367
|
+
selected.push(candidate);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return selected;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function estimateSceneTime(index, total, durationSeconds) {
|
|
374
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || total <= 0) {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
return durationSeconds * (index + 1) / (total + 1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function buildImageSheetFromPattern(inputDir, outputPath, tile) {
|
|
381
|
+
const files = await listJpegs(inputDir);
|
|
382
|
+
if (files.length === 0) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
await run("ffmpeg", [
|
|
386
|
+
"-y",
|
|
387
|
+
"-pattern_type",
|
|
388
|
+
"glob",
|
|
389
|
+
"-i",
|
|
390
|
+
join(inputDir, "*.jpg"),
|
|
391
|
+
"-vf",
|
|
392
|
+
`scale=180:-1,tile=${tile}`,
|
|
393
|
+
"-frames:v",
|
|
394
|
+
"1",
|
|
395
|
+
"-update",
|
|
396
|
+
"1",
|
|
397
|
+
outputPath
|
|
398
|
+
]);
|
|
399
|
+
return outputPath;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function listJpegs(dir) {
|
|
403
|
+
const entries = await readdir(dir);
|
|
404
|
+
return entries
|
|
405
|
+
.filter((entry) => /\.(jpe?g)$/iu.test(entry))
|
|
406
|
+
.sort((left, right) => left.localeCompare(right));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function parsePositiveNumber(value, fallback) {
|
|
410
|
+
const parsed = Number.parseFloat(value ?? "");
|
|
411
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parsePositiveInteger(value, fallback) {
|
|
415
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
416
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function resolveDefaultOutputDir(input) {
|
|
420
|
+
if (input.outputDir) {
|
|
421
|
+
return resolve(input.outputDir);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const artifactName = sanitizeArtifactName(input.artifactName || input.videoStem);
|
|
425
|
+
if (input.taskRoot) {
|
|
426
|
+
return join(resolve(input.taskRoot), ".optimus", "artifacts", "video-keyframes", artifactName);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const inferredTaskRoot = await findNearestTaskRoot(process.cwd());
|
|
430
|
+
if (inferredTaskRoot) {
|
|
431
|
+
return join(inferredTaskRoot, ".optimus", "artifacts", "video-keyframes", artifactName);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return resolve(dirname(input.videoPath), `${input.videoStem}-keyframes`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function findNearestTaskRoot(startDir) {
|
|
438
|
+
let current = resolve(startDir);
|
|
439
|
+
while (true) {
|
|
440
|
+
if (await pathExists(join(current, ".optimus"))) {
|
|
441
|
+
return current;
|
|
442
|
+
}
|
|
443
|
+
const parent = dirname(current);
|
|
444
|
+
if (parent === current) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
current = parent;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function pathExists(path) {
|
|
452
|
+
try {
|
|
453
|
+
await access(path);
|
|
454
|
+
return true;
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function sanitizeArtifactName(value) {
|
|
461
|
+
const normalized = String(value || "video")
|
|
462
|
+
.trim()
|
|
463
|
+
.replace(/[^a-zA-Z0-9._-]+/gu, "-")
|
|
464
|
+
.replace(/^-+|-+$/gu, "");
|
|
465
|
+
return normalized || "video";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function parseContactFps(value) {
|
|
469
|
+
return (value || DEFAULT_CONTACT_FPS)
|
|
470
|
+
.split(",")
|
|
471
|
+
.map((item) => Number.parseFloat(item.trim()))
|
|
472
|
+
.filter((item) => Number.isFinite(item) && item > 0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatSeconds(value) {
|
|
476
|
+
return Math.max(value, 0).toFixed(3);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function roundSeconds(value) {
|
|
480
|
+
return Math.round(value * 1000) / 1000;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function cleanOutputArtifacts(outputDir) {
|
|
484
|
+
await mkdir(outputDir, { recursive: true });
|
|
485
|
+
await rm(join(outputDir, "frames"), { recursive: true, force: true });
|
|
486
|
+
await rm(join(outputDir, "keyframes"), { recursive: true, force: true });
|
|
487
|
+
await rm(join(outputDir, "scene-candidates"), { recursive: true, force: true });
|
|
488
|
+
await rm(join(outputDir, "static-tail-candidates"), { recursive: true, force: true });
|
|
489
|
+
await rm(join(outputDir, "manifest.json"), { force: true });
|
|
490
|
+
await rm(join(outputDir, "summary.md"), { force: true });
|
|
491
|
+
await rm(join(outputDir, "contact-keyframes.jpg"), { force: true });
|
|
492
|
+
for (const file of await readdir(outputDir).catch(() => [])) {
|
|
493
|
+
if (/^contact-[0-9_]+fps\.jpg$/u.test(file)) {
|
|
494
|
+
await rm(join(outputDir, file), { force: true });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function writeSummary(outputPath, manifest) {
|
|
500
|
+
const reasonCounts = manifest.keyframes.reduce((counts, frame) => {
|
|
501
|
+
counts[frame.reason] = (counts[frame.reason] ?? 0) + 1;
|
|
502
|
+
return counts;
|
|
503
|
+
}, {});
|
|
504
|
+
const lines = [
|
|
505
|
+
"# Video Keyframe Summary",
|
|
506
|
+
"",
|
|
507
|
+
`- Video: ${manifest.video}`,
|
|
508
|
+
`- Duration: ${manifest.duration_seconds}s`,
|
|
509
|
+
`- Sample FPS: ${manifest.sampled_frame_fps}`,
|
|
510
|
+
`- Scene threshold: ${manifest.scene_threshold}`,
|
|
511
|
+
`- Static keep seconds: ${manifest.static_keep_seconds}`,
|
|
512
|
+
`- Keyframes: ${manifest.keyframes.length} (${Object.entries(reasonCounts).map(([key, value]) => `${key}=${value}`).join(", ")})`,
|
|
513
|
+
`- Keyframe sheet: ${manifest.keyframe_sheet ?? "not generated"}`,
|
|
514
|
+
"",
|
|
515
|
+
"## Keyframes",
|
|
516
|
+
"",
|
|
517
|
+
"| # | Time | Reason | Path |",
|
|
518
|
+
"|---:|---:|---|---|",
|
|
519
|
+
...manifest.keyframes.map((frame) => `| ${frame.index} | ${frame.time_seconds}s | ${frame.reason} | ${frame.path} |`),
|
|
520
|
+
"",
|
|
521
|
+
"## Notes",
|
|
522
|
+
"",
|
|
523
|
+
"- `visual_change` frames are selected by ffmpeg scene-change detection.",
|
|
524
|
+
"- `static_tail` frames preserve the state immediately before a long static span ends.",
|
|
525
|
+
"- First and last frames are always preserved for entry and exit context."
|
|
526
|
+
];
|
|
527
|
+
await writeFile(outputPath, lines.join("\n"), "utf8");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function main() {
|
|
531
|
+
const args = parseArgs(process.argv.slice(2));
|
|
532
|
+
if (args.help || !args.video) {
|
|
533
|
+
console.log(usage());
|
|
534
|
+
process.exitCode = args.help ? 0 : 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
await ensureTool("ffmpeg");
|
|
539
|
+
await ensureTool("ffprobe");
|
|
540
|
+
|
|
541
|
+
const videoPath = resolve(String(args.video));
|
|
542
|
+
const videoStem = basename(videoPath, extname(videoPath));
|
|
543
|
+
const outputDir = await resolveDefaultOutputDir({
|
|
544
|
+
videoPath,
|
|
545
|
+
videoStem,
|
|
546
|
+
outputDir: args["output-dir"] ? String(args["output-dir"]) : undefined,
|
|
547
|
+
taskRoot: args["task-root"] ? String(args["task-root"]) : undefined,
|
|
548
|
+
artifactName: args["artifact-name"] ? String(args["artifact-name"]) : undefined
|
|
549
|
+
});
|
|
550
|
+
const sampleFps = parsePositiveNumber(args["sample-fps"], DEFAULT_SAMPLE_FPS);
|
|
551
|
+
const sceneThreshold = parsePositiveNumber(args["scene-threshold"], DEFAULT_SCENE_THRESHOLD);
|
|
552
|
+
const minKeyframeGapSeconds = parsePositiveNumber(args["min-keyframe-gap"], DEFAULT_MIN_KEYFRAME_GAP_SECONDS);
|
|
553
|
+
const staticKeepSeconds = parsePositiveNumber(args["static-keep-seconds"], DEFAULT_STATIC_KEEP_SECONDS);
|
|
554
|
+
const maxKeyframes = parsePositiveInteger(args["max-keyframes"], DEFAULT_MAX_KEYFRAMES);
|
|
555
|
+
const tile = String(args.tile ?? DEFAULT_TILE);
|
|
556
|
+
const contactFps = parseContactFps(String(args["contact-fps"] ?? DEFAULT_CONTACT_FPS));
|
|
557
|
+
|
|
558
|
+
await cleanOutputArtifacts(outputDir);
|
|
559
|
+
|
|
560
|
+
const durationSeconds = await readDuration(videoPath);
|
|
561
|
+
const framesDir = join(outputDir, "frames");
|
|
562
|
+
const sampledFrameCount = await extractSampledFrames(videoPath, framesDir, sampleFps);
|
|
563
|
+
|
|
564
|
+
const contactSheets = [];
|
|
565
|
+
for (const fps of contactFps) {
|
|
566
|
+
const label = String(fps).replace(".", "_");
|
|
567
|
+
const outputPath = join(outputDir, `contact-${label}fps.jpg`);
|
|
568
|
+
await extractContactSheet(videoPath, outputPath, fps, tile);
|
|
569
|
+
contactSheets.push(outputPath);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const keyframes = await buildKeyframes({
|
|
573
|
+
videoPath,
|
|
574
|
+
outputDir,
|
|
575
|
+
durationSeconds,
|
|
576
|
+
sceneThreshold,
|
|
577
|
+
minKeyframeGapSeconds,
|
|
578
|
+
staticKeepSeconds,
|
|
579
|
+
maxKeyframes
|
|
580
|
+
});
|
|
581
|
+
const keyframeSheet = await buildImageSheetFromPattern(join(outputDir, "keyframes"), join(outputDir, "contact-keyframes.jpg"), tile);
|
|
582
|
+
|
|
583
|
+
const manifest = {
|
|
584
|
+
video: videoPath,
|
|
585
|
+
duration_seconds: roundSeconds(durationSeconds),
|
|
586
|
+
output_dir: outputDir,
|
|
587
|
+
frames_dir: framesDir,
|
|
588
|
+
sampled_frame_fps: sampleFps,
|
|
589
|
+
sampled_frame_count: sampledFrameCount,
|
|
590
|
+
scene_threshold: sceneThreshold,
|
|
591
|
+
min_keyframe_gap_seconds: minKeyframeGapSeconds,
|
|
592
|
+
static_keep_seconds: staticKeepSeconds,
|
|
593
|
+
max_keyframes: maxKeyframes,
|
|
594
|
+
contact_sheets: contactSheets,
|
|
595
|
+
keyframe_sheet: keyframeSheet,
|
|
596
|
+
keyframes,
|
|
597
|
+
tile
|
|
598
|
+
};
|
|
599
|
+
const manifestPath = join(outputDir, "manifest.json");
|
|
600
|
+
const summaryPath = join(outputDir, "summary.md");
|
|
601
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
|
|
602
|
+
await writeSummary(summaryPath, {
|
|
603
|
+
...manifest,
|
|
604
|
+
summary_path: summaryPath
|
|
605
|
+
});
|
|
606
|
+
manifest.summary_path = summaryPath;
|
|
607
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
|
|
608
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
main().catch((error) => {
|
|
612
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
613
|
+
process.exitCode = 1;
|
|
614
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sireai/optimus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Optimus Codex-native background task runtime and harness scaffolding.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
"start": "node dist/cli/optimus.js start",
|
|
48
48
|
"version": "node dist/cli/optimus.js version",
|
|
49
49
|
"upgrade": "node dist/cli/optimus.js upgrade",
|
|
50
|
+
"feishu-auth-login": "node dist/cli/optimus.js feishu auth login",
|
|
51
|
+
"feishu-auth-status": "node dist/cli/optimus.js feishu auth status",
|
|
52
|
+
"feishu-auth-logout": "node dist/cli/optimus.js feishu auth logout",
|
|
50
53
|
"release:status": "node scripts/release.mjs status",
|
|
51
54
|
"release:prepare": "node scripts/release.mjs prepare",
|
|
52
55
|
"release:preflight": "node scripts/release.mjs preflight",
|
|
@@ -94,7 +97,8 @@
|
|
|
94
97
|
"jira-poll-once": "node dist/integrations/jira/jira-cli.js poll-once",
|
|
95
98
|
"jira-poll-daemon": "node dist/integrations/jira/jira-cli.js poll-daemon",
|
|
96
99
|
"sentry-get-event": "node dist/integrations/sentry/sentry-cli.js get-event",
|
|
97
|
-
"sentry-submit-event": "node dist/integrations/sentry/sentry-cli.js submit-event"
|
|
100
|
+
"sentry-submit-event": "node dist/integrations/sentry/sentry-cli.js submit-event",
|
|
101
|
+
"video-keyframes": "node embedded-skills/shared/video-keyframe-analyzer/scripts/extract-keyframes.mjs"
|
|
98
102
|
},
|
|
99
103
|
"keywords": [
|
|
100
104
|
"optimus",
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# Repo Inspection
|
|
2
|
-
|
|
3
|
-
Use this skill when the task requires quickly understanding repository layout, module boundaries, entrypoints, or key build and test commands before making changes.
|
|
4
|
-
|
|
5
|
-
## Workflow
|
|
6
|
-
- Read the task package first and identify the suspected repository area.
|
|
7
|
-
- Inspect repository structure, build files, and task-relevant docs before editing.
|
|
8
|
-
- Summarize the likely execution chain and the files that matter most to the bug.
|
|
9
|
-
- Prefer targeted searches and file reads over broad scans.
|