@lumy-pack/scene-sieve 0.0.14 → 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/README.md +47 -24
- package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/{utils → cli/options}/parse-options.d.ts +6 -1
- package/dist/cli.mjs +2437 -2126
- package/dist/constants/pipeline-defaults.d.ts +13 -0
- package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
- package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
- package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
- package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
- package/dist/core/analyzer/features/frame-features.d.ts +32 -0
- package/dist/core/analyzer/index.d.ts +3 -0
- package/dist/core/constants/workspace-layout.d.ts +9 -0
- package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
- package/dist/core/extractor/index.d.ts +1 -0
- package/dist/core/index.d.ts +8 -9
- package/dist/core/input-resolver/index.d.ts +1 -0
- package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
- package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
- package/dist/core/orchestrator/index.d.ts +3 -0
- package/dist/core/{orchestrator.d.ts → orchestrator/orchestrator.d.ts} +1 -1
- package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
- package/dist/core/pruner/index.d.ts +1 -0
- package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
- package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
- package/dist/core/segmenter/index.d.ts +1 -0
- package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
- package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
- package/dist/core/workspace/index.d.ts +1 -0
- package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
- package/dist/index.cjs +1854 -1486
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +1836 -1455
- package/dist/pipeline-worker.mjs +1734 -1474
- package/dist/types/index.d.ts +25 -0
- package/package.json +5 -4
- package/dist/constants.d.ts +0 -32
- /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
- /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
- /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
- /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
- /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
- /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
- /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
- /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
- /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -1,2255 +1,2566 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { Box, Text, render, useApp } from "ink";
|
|
6
|
+
import React, { useEffect, useState } from "react";
|
|
7
|
+
import Spinner from "ink-spinner";
|
|
8
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { filter, map } from "@winglet/common-utils";
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
import sharp from "sharp";
|
|
13
|
+
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
15
|
+
import { path } from "@ffprobe-installer/ffprobe";
|
|
16
|
+
import { execa } from "execa";
|
|
17
|
+
import ffmpegPath from "ffmpeg-static";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { Worker } from "node:worker_threads";
|
|
21
|
+
|
|
22
|
+
//#region \0rolldown/runtime.js
|
|
2
23
|
var __defProp = Object.defineProperty;
|
|
3
|
-
var
|
|
4
|
-
|
|
5
|
-
|
|
24
|
+
var __esmMin = (fn, res, err) => () => {
|
|
25
|
+
if (err) throw err[0];
|
|
26
|
+
try {
|
|
27
|
+
return fn && (res = fn(fn = 0)), res;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw err = [e], e;
|
|
30
|
+
}
|
|
6
31
|
};
|
|
7
|
-
var
|
|
8
|
-
|
|
9
|
-
|
|
32
|
+
var __exportAll = (all, no_symbols) => {
|
|
33
|
+
let target = {};
|
|
34
|
+
for (var name in all) {
|
|
35
|
+
__defProp(target, name, {
|
|
36
|
+
get: all[name],
|
|
37
|
+
enumerable: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (!no_symbols) {
|
|
41
|
+
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
42
|
+
}
|
|
43
|
+
return target;
|
|
10
44
|
};
|
|
11
45
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region ../shared/src/respond.ts
|
|
48
|
+
/**
|
|
49
|
+
* Write a successful JSON response to stdout.
|
|
50
|
+
*/
|
|
51
|
+
function respond(command, data, startTime, version) {
|
|
52
|
+
const response = {
|
|
53
|
+
ok: true,
|
|
54
|
+
command,
|
|
55
|
+
data,
|
|
56
|
+
meta: {
|
|
57
|
+
version,
|
|
58
|
+
durationMs: Date.now() - startTime,
|
|
59
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Write an error JSON response to stdout and set exit code to 1.
|
|
66
|
+
*/
|
|
67
|
+
function respondError(command, code, message, startTime, version, details) {
|
|
68
|
+
const response = {
|
|
69
|
+
ok: false,
|
|
70
|
+
command,
|
|
71
|
+
error: {
|
|
72
|
+
code,
|
|
73
|
+
message,
|
|
74
|
+
...details !== void 0 ? { details } : {}
|
|
75
|
+
},
|
|
76
|
+
meta: {
|
|
77
|
+
version,
|
|
78
|
+
durationMs: Date.now() - startTime,
|
|
79
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
52
85
|
|
|
53
|
-
|
|
54
|
-
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/cli/components/ProgressBar.tsx
|
|
88
|
+
const ProgressBar = ({ percent, width = 30 }) => {
|
|
89
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
90
|
+
const filled = Math.round(width * (clamped / 100));
|
|
91
|
+
const empty = width - filled;
|
|
92
|
+
return /* @__PURE__ */ jsxs(Text, { children: [
|
|
93
|
+
/* @__PURE__ */ jsx(Text, {
|
|
94
|
+
color: "green",
|
|
95
|
+
children: "█".repeat(filled)
|
|
96
|
+
}),
|
|
97
|
+
/* @__PURE__ */ jsx(Text, {
|
|
98
|
+
color: "gray",
|
|
99
|
+
children: "░".repeat(empty)
|
|
100
|
+
}),
|
|
101
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
102
|
+
" ",
|
|
103
|
+
clamped,
|
|
104
|
+
"%"
|
|
105
|
+
] })
|
|
106
|
+
] });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/cli/components/PhaseStep.tsx
|
|
111
|
+
const PhaseStep = ({ phase }) => {
|
|
112
|
+
const icon = (() => {
|
|
113
|
+
switch (phase.status) {
|
|
114
|
+
case "done": return /* @__PURE__ */ jsx(Text, {
|
|
115
|
+
color: "green",
|
|
116
|
+
children: "✓"
|
|
117
|
+
});
|
|
118
|
+
case "running": return /* @__PURE__ */ jsx(Text, {
|
|
119
|
+
color: "yellow",
|
|
120
|
+
children: /* @__PURE__ */ jsx(Spinner, { type: "dots" })
|
|
121
|
+
});
|
|
122
|
+
case "failed": return /* @__PURE__ */ jsx(Text, {
|
|
123
|
+
color: "red",
|
|
124
|
+
children: "✗"
|
|
125
|
+
});
|
|
126
|
+
default: return /* @__PURE__ */ jsx(Text, {
|
|
127
|
+
color: "gray",
|
|
128
|
+
children: "○"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
const duration = phase.status === "done" && phase.durationMs !== void 0 ? `Done (${Math.round(phase.durationMs / 1e3)}s)` : "";
|
|
133
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
134
|
+
flexDirection: "column",
|
|
135
|
+
children: [/* @__PURE__ */ jsxs(Text, { children: [
|
|
136
|
+
" ",
|
|
137
|
+
icon,
|
|
138
|
+
" ",
|
|
139
|
+
phase.label,
|
|
140
|
+
duration ? /* @__PURE__ */ jsxs(Text, {
|
|
141
|
+
color: "gray",
|
|
142
|
+
children: [" ", duration]
|
|
143
|
+
}) : null
|
|
144
|
+
] }), phase.status === "running" && phase.hasProgress && phase.percent > 0 && /* @__PURE__ */ jsxs(Text, { children: [" ", /* @__PURE__ */ jsx(ProgressBar, { percent: phase.percent })] })]
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/constants/pipeline-defaults.ts
|
|
150
|
+
var DEFAULT_THRESHOLD, IOU_THRESHOLD;
|
|
151
|
+
var init_pipeline_defaults = __esmMin((() => {
|
|
152
|
+
DEFAULT_THRESHOLD = .5;
|
|
153
|
+
IOU_THRESHOLD = .9;
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/logging/logger.ts
|
|
55
158
|
function setDebugMode(enabled) {
|
|
56
|
-
|
|
159
|
+
debugMode = enabled;
|
|
57
160
|
}
|
|
58
161
|
function setJsonMode(enabled) {
|
|
59
|
-
|
|
162
|
+
jsonMode = enabled;
|
|
60
163
|
}
|
|
61
164
|
function timestamp() {
|
|
62
|
-
|
|
165
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
63
166
|
}
|
|
64
167
|
var debugMode, jsonMode, logger;
|
|
65
|
-
var init_logger =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
90
|
-
},
|
|
91
|
-
error(message) {
|
|
92
|
-
console.error(`${pc.red("error")} ${message}`);
|
|
93
|
-
},
|
|
94
|
-
debug(message) {
|
|
95
|
-
if (debugMode) {
|
|
96
|
-
if (jsonMode) {
|
|
97
|
-
process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}
|
|
98
|
-
`);
|
|
99
|
-
} else {
|
|
100
|
-
console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
});
|
|
168
|
+
var init_logger = __esmMin((() => {
|
|
169
|
+
debugMode = false;
|
|
170
|
+
jsonMode = false;
|
|
171
|
+
logger = {
|
|
172
|
+
info(message) {
|
|
173
|
+
if (jsonMode) process.stderr.write(`${pc.blue("info")} ${message}\n`);
|
|
174
|
+
else console.log(`${pc.blue("info")} ${message}`);
|
|
175
|
+
},
|
|
176
|
+
success(message) {
|
|
177
|
+
if (jsonMode) process.stderr.write(`${pc.green("done")} ${message}\n`);
|
|
178
|
+
else console.log(`\n${pc.green("done")} ${message}`);
|
|
179
|
+
},
|
|
180
|
+
warn(message) {
|
|
181
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
182
|
+
},
|
|
183
|
+
error(message) {
|
|
184
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
185
|
+
},
|
|
186
|
+
debug(message) {
|
|
187
|
+
if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
|
|
188
|
+
else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}));
|
|
107
192
|
|
|
108
|
-
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/core/analyzer/constants/vision-tuning.ts
|
|
195
|
+
var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
|
|
196
|
+
var init_vision_tuning = __esmMin((() => {
|
|
197
|
+
DBSCAN_ALPHA = .03;
|
|
198
|
+
DECAY_LAMBDA = .95;
|
|
199
|
+
MATCH_DISTANCE_THRESHOLD = .25;
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/core/analyzer/clustering/dbscan.ts
|
|
204
|
+
/**
|
|
205
|
+
* DBSCAN clustering with resolution-independent eps.
|
|
206
|
+
* eps = alpha * sqrt(width^2 + height^2)
|
|
207
|
+
*/
|
|
109
208
|
function dbscan(points, imageWidth, imageHeight, alpha, minPts) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
209
|
+
if (points.length === 0) return {
|
|
210
|
+
labels: [],
|
|
211
|
+
boundingBoxes: []
|
|
212
|
+
};
|
|
213
|
+
const eps = (alpha ?? .03) * Math.sqrt(imageWidth ** 2 + imageHeight ** 2);
|
|
214
|
+
const epsSquared = eps * eps;
|
|
215
|
+
const minPoints = minPts ?? 4;
|
|
216
|
+
const labels = new Array(points.length).fill(UNVISITED);
|
|
217
|
+
let clusterId = 0;
|
|
218
|
+
for (let i = 0; i < points.length; i++) {
|
|
219
|
+
if (labels[i] !== UNVISITED) continue;
|
|
220
|
+
const neighbors = findNeighbors(points, i, epsSquared);
|
|
221
|
+
if (neighbors.length < minPoints) {
|
|
222
|
+
labels[i] = NOISE;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
labels[i] = clusterId;
|
|
226
|
+
const seeds = [...neighbors];
|
|
227
|
+
const seedSet = new Set(seeds);
|
|
228
|
+
for (let si = 0; si < seeds.length; si++) {
|
|
229
|
+
const q = seeds[si];
|
|
230
|
+
if (labels[q] === NOISE) labels[q] = clusterId;
|
|
231
|
+
if (labels[q] !== UNVISITED) continue;
|
|
232
|
+
labels[q] = clusterId;
|
|
233
|
+
const qNeighbors = findNeighbors(points, q, epsSquared);
|
|
234
|
+
if (qNeighbors.length >= minPoints) {
|
|
235
|
+
for (const n of qNeighbors) if (!seedSet.has(n)) {
|
|
236
|
+
seedSet.add(n);
|
|
237
|
+
seeds.push(n);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
clusterId++;
|
|
242
|
+
}
|
|
243
|
+
const boundingBoxes = [];
|
|
244
|
+
for (let c = 0; c < clusterId; c++) {
|
|
245
|
+
let minX = Infinity;
|
|
246
|
+
let minY = Infinity;
|
|
247
|
+
let maxX = -Infinity;
|
|
248
|
+
let maxY = -Infinity;
|
|
249
|
+
for (let i = 0; i < points.length; i++) {
|
|
250
|
+
if (labels[i] !== c) continue;
|
|
251
|
+
const p = points[i];
|
|
252
|
+
if (p.x < minX) minX = p.x;
|
|
253
|
+
if (p.y < minY) minY = p.y;
|
|
254
|
+
if (p.x > maxX) maxX = p.x;
|
|
255
|
+
if (p.y > maxY) maxY = p.y;
|
|
256
|
+
}
|
|
257
|
+
boundingBoxes.push({
|
|
258
|
+
x: minX,
|
|
259
|
+
y: minY,
|
|
260
|
+
width: maxX - minX,
|
|
261
|
+
height: maxY - minY
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
labels,
|
|
266
|
+
boundingBoxes
|
|
267
|
+
};
|
|
169
268
|
}
|
|
170
269
|
function findNeighbors(points, idx, epsSquared) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return neighbors;
|
|
270
|
+
const p = points[idx];
|
|
271
|
+
const neighbors = [];
|
|
272
|
+
for (let i = 0; i < points.length; i++) {
|
|
273
|
+
if (i === idx) continue;
|
|
274
|
+
const q = points[i];
|
|
275
|
+
if ((p.x - q.x) ** 2 + (p.y - q.y) ** 2 <= epsSquared) neighbors.push(i);
|
|
276
|
+
}
|
|
277
|
+
return neighbors;
|
|
182
278
|
}
|
|
183
279
|
var UNVISITED, NOISE;
|
|
184
|
-
var init_dbscan =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
NOISE = -1;
|
|
190
|
-
}
|
|
191
|
-
});
|
|
280
|
+
var init_dbscan = __esmMin((() => {
|
|
281
|
+
init_vision_tuning();
|
|
282
|
+
UNVISITED = -2;
|
|
283
|
+
NOISE = -1;
|
|
284
|
+
}));
|
|
192
285
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/core/analyzer/features/feature-diff.ts
|
|
288
|
+
/**
|
|
289
|
+
* Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
|
|
290
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
291
|
+
* @param prev - Previous frame's live features, owned by the caller.
|
|
292
|
+
* @param next - Next frame's live features, owned by the caller.
|
|
293
|
+
* @returns Unmatched next-frame coordinates without changing input ownership.
|
|
294
|
+
* @throws Propagates matching errors after releasing temporary native handles.
|
|
295
|
+
*/
|
|
296
|
+
function computeNewPoints(cvLib, prev, next) {
|
|
297
|
+
let matcher = null;
|
|
298
|
+
let matches = null;
|
|
299
|
+
try {
|
|
300
|
+
const matchedIndices = /* @__PURE__ */ new Set();
|
|
301
|
+
if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
|
|
302
|
+
matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
303
|
+
matches = new cvLib.DMatchVectorVector();
|
|
304
|
+
matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
|
|
305
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
306
|
+
const pair = matches.get(i);
|
|
307
|
+
try {
|
|
308
|
+
if (pair.size() < 2) continue;
|
|
309
|
+
const best = pair.get(0);
|
|
310
|
+
const second = pair.get(1);
|
|
311
|
+
if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
|
|
312
|
+
} finally {
|
|
313
|
+
pair.delete();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
matcher?.delete();
|
|
318
|
+
}
|
|
319
|
+
const points = [];
|
|
320
|
+
for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
|
|
321
|
+
const { x, y } = next.keypoints.get(i).pt;
|
|
322
|
+
points.push({
|
|
323
|
+
x,
|
|
324
|
+
y
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return points;
|
|
328
|
+
} finally {
|
|
329
|
+
matches?.delete();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
var init_feature_diff = __esmMin((() => {
|
|
333
|
+
init_vision_tuning();
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/core/analyzer/features/frame-features.ts
|
|
338
|
+
/**
|
|
339
|
+
* Detect one frame's features without retaining its image or mask.
|
|
340
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
341
|
+
* @param akaze - Detector owned and released by the caller.
|
|
342
|
+
* @param frame - Grayscale bytes with matching width and height.
|
|
343
|
+
* @returns Feature handles that the caller must delete.
|
|
344
|
+
* @throws Propagates native errors after releasing partial allocations.
|
|
345
|
+
*/
|
|
346
|
+
function computeFrameFeatures(cvLib, akaze, frame) {
|
|
347
|
+
let image = null;
|
|
348
|
+
let mask = null;
|
|
349
|
+
let keypoints = null;
|
|
350
|
+
let descriptors = null;
|
|
351
|
+
try {
|
|
352
|
+
image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
|
|
353
|
+
image.data.set(frame.data);
|
|
354
|
+
mask = new cvLib.Mat();
|
|
355
|
+
keypoints = new cvLib.KeyPointVector();
|
|
356
|
+
descriptors = new cvLib.Mat();
|
|
357
|
+
akaze.detectAndCompute(image, mask, keypoints, descriptors);
|
|
358
|
+
const ownedKeypoints = keypoints;
|
|
359
|
+
const ownedDescriptors = descriptors;
|
|
360
|
+
let deleted = false;
|
|
361
|
+
const features = {
|
|
362
|
+
width: frame.width,
|
|
363
|
+
height: frame.height,
|
|
364
|
+
keypoints: ownedKeypoints,
|
|
365
|
+
descriptors: ownedDescriptors,
|
|
366
|
+
/** Release the transferred handles exactly once. */
|
|
367
|
+
delete() {
|
|
368
|
+
if (deleted) return;
|
|
369
|
+
deleted = true;
|
|
370
|
+
try {
|
|
371
|
+
ownedKeypoints.delete();
|
|
372
|
+
} finally {
|
|
373
|
+
ownedDescriptors.delete();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
keypoints = null;
|
|
378
|
+
descriptors = null;
|
|
379
|
+
return features;
|
|
380
|
+
} finally {
|
|
381
|
+
image?.delete();
|
|
382
|
+
mask?.delete();
|
|
383
|
+
keypoints?.delete();
|
|
384
|
+
descriptors?.delete();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
var init_frame_features = __esmMin((() => {}));
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/core/analyzer/analyzer.ts
|
|
197
391
|
async function ensureOpenCV() {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
return cvReady;
|
|
392
|
+
if (!cvReady) cvReady = (async () => {
|
|
393
|
+
const cvObj = require("@techstark/opencv-js");
|
|
394
|
+
delete cvObj.then;
|
|
395
|
+
if (cvObj.Mat) return cvObj;
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
const timeout = setTimeout(() => {
|
|
398
|
+
reject(/* @__PURE__ */ new Error("OpenCV WASM initialization timed out after 30s"));
|
|
399
|
+
}, OPENCV_INIT_TIMEOUT_MS);
|
|
400
|
+
cvObj.onRuntimeInitialized = () => {
|
|
401
|
+
clearTimeout(timeout);
|
|
402
|
+
resolve(cvObj);
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
})();
|
|
406
|
+
return cvReady;
|
|
215
407
|
}
|
|
216
408
|
async function preprocessFrame(framePath, scale) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
409
|
+
const { data, info } = await sharp(framePath).resize({
|
|
410
|
+
width: scale,
|
|
411
|
+
withoutEnlargement: true
|
|
412
|
+
}).grayscale().blur(1).raw().toBuffer({ resolveWithObject: true });
|
|
413
|
+
return {
|
|
414
|
+
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
415
|
+
width: info.width,
|
|
416
|
+
height: info.height
|
|
417
|
+
};
|
|
223
418
|
}
|
|
224
419
|
function computeIoU(a, b) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const aArea = a.width * a.height;
|
|
234
|
-
const bArea = b.width * b.height;
|
|
235
|
-
const union = aArea + bArea - intersection;
|
|
236
|
-
return union === 0 ? 0 : intersection / union;
|
|
237
|
-
}
|
|
238
|
-
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
239
|
-
const cv = cvLib;
|
|
240
|
-
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
241
|
-
mat1.data.set(frame1.data);
|
|
242
|
-
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
243
|
-
mat2.data.set(frame2.data);
|
|
244
|
-
const kp1 = new cvLib.KeyPointVector();
|
|
245
|
-
const kp2 = new cvLib.KeyPointVector();
|
|
246
|
-
const desc1 = new cvLib.Mat();
|
|
247
|
-
const desc2 = new cvLib.Mat();
|
|
248
|
-
const mask1 = new cvLib.Mat();
|
|
249
|
-
const mask2 = new cvLib.Mat();
|
|
250
|
-
const akaze = new cvLib.AKAZE();
|
|
251
|
-
let matches = null;
|
|
252
|
-
try {
|
|
253
|
-
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
254
|
-
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
255
|
-
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
256
|
-
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
257
|
-
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
258
|
-
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
259
|
-
try {
|
|
260
|
-
matches = new cvLib.DMatchVectorVector();
|
|
261
|
-
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
262
|
-
for (let i = 0; i < matches.size(); i++) {
|
|
263
|
-
const pair = matches.get(i);
|
|
264
|
-
if (pair.size() < 2) continue;
|
|
265
|
-
const m0 = pair.get(0);
|
|
266
|
-
const m1 = pair.get(1);
|
|
267
|
-
if (m0.distance < MATCH_DISTANCE_THRESHOLD * m1.distance) {
|
|
268
|
-
matchedKp1Indices.add(m0.queryIdx);
|
|
269
|
-
matchedKp2Indices.add(m0.trainIdx);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
} finally {
|
|
273
|
-
matcher.delete();
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const sNew = [];
|
|
277
|
-
for (let i = 0; i < kp2.size(); i++) {
|
|
278
|
-
if (!matchedKp2Indices.has(i)) {
|
|
279
|
-
const pt = kp2.get(i).pt;
|
|
280
|
-
sNew.push({ x: pt.x, y: pt.y });
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const sLoss = [];
|
|
284
|
-
for (let i = 0; i < kp1.size(); i++) {
|
|
285
|
-
if (!matchedKp1Indices.has(i)) {
|
|
286
|
-
const pt = kp1.get(i).pt;
|
|
287
|
-
sLoss.push({ x: pt.x, y: pt.y });
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return { sNew, sLoss };
|
|
291
|
-
} finally {
|
|
292
|
-
mat1.delete();
|
|
293
|
-
mat2.delete();
|
|
294
|
-
kp1.delete();
|
|
295
|
-
kp2.delete();
|
|
296
|
-
desc1.delete();
|
|
297
|
-
desc2.delete();
|
|
298
|
-
mask1.delete();
|
|
299
|
-
mask2.delete();
|
|
300
|
-
akaze.delete();
|
|
301
|
-
if (matches) matches.delete();
|
|
302
|
-
}
|
|
420
|
+
const ix1 = Math.max(a.x, b.x);
|
|
421
|
+
const iy1 = Math.max(a.y, b.y);
|
|
422
|
+
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
|
423
|
+
const iy2 = Math.min(a.y + a.height, b.y + b.height);
|
|
424
|
+
const intersection = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
|
|
425
|
+
if (intersection === 0) return 0;
|
|
426
|
+
const union = a.width * a.height + b.width * b.height - intersection;
|
|
427
|
+
return union === 0 ? 0 : intersection / union;
|
|
303
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Pixel-level difference fallback for AKAZE blind spots.
|
|
431
|
+
*
|
|
432
|
+
* When AKAZE produces sparse results (typical for UI screen recordings
|
|
433
|
+
* where form fields, dropdowns, or overlays change), this function
|
|
434
|
+
* detects changed regions via cv.absdiff and generates synthetic
|
|
435
|
+
* Point2D[] that feed into the existing DBSCAN → IoU → G(t) pipeline.
|
|
436
|
+
*
|
|
437
|
+
* Algorithm:
|
|
438
|
+
* 1. absdiff(frame1, frame2) → grayscale difference
|
|
439
|
+
* 2. GaussianBlur → reduce JPEG compression noise
|
|
440
|
+
* 3. threshold → binary mask of significant changes
|
|
441
|
+
* 4. findContours → bounding rects of changed regions
|
|
442
|
+
* 5. Grid sampling within each bounding rect → Point2D[]
|
|
443
|
+
*
|
|
444
|
+
* @param cvLib - Initialized OpenCV runtime shared by the analyzer.
|
|
445
|
+
* @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
|
|
446
|
+
* @param frame2 - Next grayscale frame, with the same dimensions as frame1.
|
|
447
|
+
* @returns Grid-sampled points from changed regions.
|
|
448
|
+
* @throws Propagates allocation or OpenCV errors after releasing acquired handles.
|
|
449
|
+
*/
|
|
304
450
|
function computePixelDiff(cvLib, frame1, frame2) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
blurred.delete();
|
|
353
|
-
binary.delete();
|
|
354
|
-
contours.delete();
|
|
355
|
-
hierarchy.delete();
|
|
356
|
-
}
|
|
451
|
+
const cv = cvLib;
|
|
452
|
+
let mat1 = null;
|
|
453
|
+
let mat2 = null;
|
|
454
|
+
let diff = null;
|
|
455
|
+
let blurred = null;
|
|
456
|
+
let binary = null;
|
|
457
|
+
let contours = null;
|
|
458
|
+
let hierarchy = null;
|
|
459
|
+
try {
|
|
460
|
+
mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
461
|
+
mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
462
|
+
diff = new cv.Mat();
|
|
463
|
+
blurred = new cv.Mat();
|
|
464
|
+
binary = new cv.Mat();
|
|
465
|
+
contours = new cv.MatVector();
|
|
466
|
+
hierarchy = new cv.Mat();
|
|
467
|
+
mat1.data.set(frame1.data);
|
|
468
|
+
mat2.data.set(frame2.data);
|
|
469
|
+
cv.absdiff(mat1, mat2, diff);
|
|
470
|
+
const ksize = new cv.Size(3, 3);
|
|
471
|
+
cv.GaussianBlur(diff, blurred, ksize, 0);
|
|
472
|
+
cv.threshold(blurred, binary, 30, 255, cv.THRESH_BINARY);
|
|
473
|
+
cv.findContours(binary, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
|
|
474
|
+
const points = [];
|
|
475
|
+
for (let c = 0; c < contours.size(); c++) {
|
|
476
|
+
const contour = contours.get(c);
|
|
477
|
+
try {
|
|
478
|
+
const rect = cv.boundingRect(contour);
|
|
479
|
+
if (rect.width * rect.height < 100) continue;
|
|
480
|
+
for (let y = rect.y; y < rect.y + rect.height; y += 8) for (let x = rect.x; x < rect.x + rect.width; x += 8) points.push({
|
|
481
|
+
x,
|
|
482
|
+
y
|
|
483
|
+
});
|
|
484
|
+
} finally {
|
|
485
|
+
contour.delete();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return points;
|
|
489
|
+
} finally {
|
|
490
|
+
mat1?.delete();
|
|
491
|
+
mat2?.delete();
|
|
492
|
+
diff?.delete();
|
|
493
|
+
blurred?.delete();
|
|
494
|
+
binary?.delete();
|
|
495
|
+
contours?.delete();
|
|
496
|
+
hierarchy?.delete();
|
|
497
|
+
}
|
|
357
498
|
}
|
|
358
499
|
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
return gain;
|
|
375
|
-
}
|
|
376
|
-
async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
377
|
-
const edges = [];
|
|
378
|
-
const preprocessed = await Promise.all(
|
|
379
|
-
map(frames, (f) => preprocessFrame(f.extractPath, scale))
|
|
380
|
-
);
|
|
381
|
-
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
382
|
-
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
383
|
-
const imageArea = imageWidth * imageHeight;
|
|
384
|
-
for (let i = 0; i < frames.length - 1; i++) {
|
|
385
|
-
const pairIndex = pairOffset + i;
|
|
386
|
-
try {
|
|
387
|
-
const { sNew } = await computeAKAZEDiff(
|
|
388
|
-
cvLib,
|
|
389
|
-
preprocessed[i],
|
|
390
|
-
preprocessed[i + 1]
|
|
391
|
-
);
|
|
392
|
-
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
393
|
-
let clusters = dbscanResult.boundingBoxes;
|
|
394
|
-
if (clusters.length === 0) {
|
|
395
|
-
const pixelDiffPoints = computePixelDiff(
|
|
396
|
-
cvLib,
|
|
397
|
-
preprocessed[i],
|
|
398
|
-
preprocessed[i + 1]
|
|
399
|
-
);
|
|
400
|
-
if (pixelDiffPoints.length > 0) {
|
|
401
|
-
logger.debug(
|
|
402
|
-
`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
|
|
403
|
-
);
|
|
404
|
-
dbscanResult = dbscan(
|
|
405
|
-
pixelDiffPoints,
|
|
406
|
-
imageWidth,
|
|
407
|
-
imageHeight,
|
|
408
|
-
void 0,
|
|
409
|
-
2
|
|
410
|
-
);
|
|
411
|
-
clusters = dbscanResult.boundingBoxes;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
415
|
-
for (const label of dbscanResult.labels) {
|
|
416
|
-
if (label >= 0) {
|
|
417
|
-
clusterPointCounts[label]++;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
const animationIndices = tracker.update(clusters, pairIndex);
|
|
421
|
-
const animationWeights = map(
|
|
422
|
-
clusters,
|
|
423
|
-
(_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0
|
|
424
|
-
);
|
|
425
|
-
const score = computeInformationGain(
|
|
426
|
-
clusters,
|
|
427
|
-
clusterPointCounts,
|
|
428
|
-
imageArea,
|
|
429
|
-
animationIndices,
|
|
430
|
-
animationWeights
|
|
431
|
-
);
|
|
432
|
-
logger.debug(
|
|
433
|
-
`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
|
|
434
|
-
);
|
|
435
|
-
edges.push({
|
|
436
|
-
sourceId: frames[i].id,
|
|
437
|
-
targetId: frames[i + 1].id,
|
|
438
|
-
score
|
|
439
|
-
});
|
|
440
|
-
} catch (err) {
|
|
441
|
-
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
442
|
-
edges.push({
|
|
443
|
-
sourceId: frames[i].id,
|
|
444
|
-
targetId: frames[i + 1].id,
|
|
445
|
-
score: 0
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return edges;
|
|
500
|
+
if (clusters.length === 0) return 0;
|
|
501
|
+
let gain = 0;
|
|
502
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
503
|
+
const box = clusters[i];
|
|
504
|
+
const clusterArea = box.width * box.height;
|
|
505
|
+
if (clusterArea <= 0) continue;
|
|
506
|
+
let contribution = clusterArea / imageArea * (clusterPoints[i] / clusterArea);
|
|
507
|
+
if (animationIndices.has(i)) {
|
|
508
|
+
const animWeight = animationWeights[i] ?? 0;
|
|
509
|
+
contribution *= 1 - animWeight;
|
|
510
|
+
}
|
|
511
|
+
gain += contribution;
|
|
512
|
+
}
|
|
513
|
+
return gain;
|
|
450
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Analyze one batch, retaining its boundary frame for the following batch.
|
|
517
|
+
* @param cvLib - Initialized OpenCV runtime.
|
|
518
|
+
* @param akaze - Detector owned by analyzeFrames.
|
|
519
|
+
* @param frames - Boundary frame followed by new adjacent frames.
|
|
520
|
+
* @param carry - Boundary bytes and live features transferred from the previous batch.
|
|
521
|
+
* @param scale - Maximum preprocessing width.
|
|
522
|
+
* @param tracker - Stateful animation tracker shared across batches.
|
|
523
|
+
* @param pairOffset - Global position of this batch's first pair.
|
|
524
|
+
* @returns Scores, pair failure count, and ownership of the final frame's features.
|
|
525
|
+
*/
|
|
526
|
+
async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
|
|
527
|
+
const edges = [];
|
|
528
|
+
let failures = 0;
|
|
529
|
+
let prev = carry?.features ?? null;
|
|
530
|
+
let next = null;
|
|
531
|
+
try {
|
|
532
|
+
const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
|
|
533
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
534
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
535
|
+
const imageArea = imageWidth * imageHeight;
|
|
536
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
537
|
+
const pairIndex = pairOffset + i;
|
|
538
|
+
try {
|
|
539
|
+
prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
|
|
540
|
+
next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
|
|
541
|
+
let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
|
|
542
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
543
|
+
if (clusters.length === 0) {
|
|
544
|
+
const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
|
|
545
|
+
if (pixelDiffPoints.length > 0) {
|
|
546
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
|
|
547
|
+
dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
|
|
548
|
+
clusters = dbscanResult.boundingBoxes;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
552
|
+
for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
|
|
553
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
554
|
+
const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
|
|
555
|
+
const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
|
|
556
|
+
logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
|
|
557
|
+
edges.push({
|
|
558
|
+
sourceId: frames[i].id,
|
|
559
|
+
targetId: frames[i + 1].id,
|
|
560
|
+
score
|
|
561
|
+
});
|
|
562
|
+
} catch (err) {
|
|
563
|
+
logger.warn(`Frame pair analysis failed: ${String(err)}`);
|
|
564
|
+
failures++;
|
|
565
|
+
edges.push({
|
|
566
|
+
sourceId: frames[i].id,
|
|
567
|
+
targetId: frames[i + 1].id,
|
|
568
|
+
score: 0
|
|
569
|
+
});
|
|
570
|
+
} finally {
|
|
571
|
+
prev?.delete();
|
|
572
|
+
prev = next;
|
|
573
|
+
next = null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const result = {
|
|
577
|
+
edges,
|
|
578
|
+
failures,
|
|
579
|
+
analysisResolution: {
|
|
580
|
+
width: imageWidth,
|
|
581
|
+
height: imageHeight
|
|
582
|
+
},
|
|
583
|
+
carry: prev ? {
|
|
584
|
+
preprocessed: preprocessed[preprocessed.length - 1],
|
|
585
|
+
features: prev
|
|
586
|
+
} : null
|
|
587
|
+
};
|
|
588
|
+
prev = null;
|
|
589
|
+
return result;
|
|
590
|
+
} finally {
|
|
591
|
+
prev?.delete();
|
|
592
|
+
next?.delete();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Analyze adjacent frame pairs to compute information gain scores (G(t)).
|
|
597
|
+
* Processes frames in batches for memory efficiency.
|
|
598
|
+
*
|
|
599
|
+
* Pipeline:
|
|
600
|
+
* 1. AKAZE Feature Set Difference
|
|
601
|
+
* 2. DBSCAN Spatial Clustering
|
|
602
|
+
* 3. Spatio-temporal IoU Tracking
|
|
603
|
+
* 4. G(t) Information Gain Scoring
|
|
604
|
+
* @param ctx - Frames, analysis options, and the progress callback for this run.
|
|
605
|
+
* @returns Adjacent scores and tracked animations in analysis coordinates.
|
|
606
|
+
* @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
|
|
607
|
+
*/
|
|
451
608
|
async function analyzeFrames(ctx) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
609
|
+
const { frames } = ctx;
|
|
610
|
+
if (frames.length < 2) return {
|
|
611
|
+
edges: [],
|
|
612
|
+
animations: [],
|
|
613
|
+
analysisResolution: {
|
|
614
|
+
width: 0,
|
|
615
|
+
height: 0
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
|
|
619
|
+
const cvLib = await ensureOpenCV();
|
|
620
|
+
const edges = [];
|
|
621
|
+
const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
|
|
622
|
+
const scale = ctx.options.scale;
|
|
623
|
+
let akaze = null;
|
|
624
|
+
let carry = null;
|
|
625
|
+
let analysisResolution = {
|
|
626
|
+
width: 0,
|
|
627
|
+
height: 0
|
|
628
|
+
};
|
|
629
|
+
let failures = 0;
|
|
630
|
+
try {
|
|
631
|
+
akaze = new cvLib.AKAZE();
|
|
632
|
+
for (let i = 0; i < frames.length - 1; i += 10) {
|
|
633
|
+
const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
|
|
634
|
+
const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
|
|
635
|
+
carry = result.carry;
|
|
636
|
+
failures += result.failures;
|
|
637
|
+
if (i === 0) analysisResolution = result.analysisResolution;
|
|
638
|
+
edges.push(...result.edges);
|
|
639
|
+
const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
|
|
640
|
+
ctx.emitProgress(progress);
|
|
641
|
+
}
|
|
642
|
+
} finally {
|
|
643
|
+
carry?.features.delete();
|
|
644
|
+
akaze?.delete();
|
|
645
|
+
}
|
|
646
|
+
const pairs = frames.length - 1;
|
|
647
|
+
if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
|
|
648
|
+
const animations = tracker.flushAndGetAnimations();
|
|
649
|
+
logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
|
|
650
|
+
return {
|
|
651
|
+
edges,
|
|
652
|
+
animations,
|
|
653
|
+
analysisResolution
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
|
|
657
|
+
var init_analyzer$1 = __esmMin((() => {
|
|
658
|
+
init_pipeline_defaults();
|
|
659
|
+
init_vision_tuning();
|
|
660
|
+
init_logger();
|
|
661
|
+
init_dbscan();
|
|
662
|
+
init_feature_diff();
|
|
663
|
+
init_frame_features();
|
|
664
|
+
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
665
|
+
require = createRequire(import.meta.url);
|
|
666
|
+
cvReady = null;
|
|
667
|
+
IoUTracker = class {
|
|
668
|
+
fps;
|
|
669
|
+
iouThreshold;
|
|
670
|
+
animationThreshold;
|
|
671
|
+
regions = [];
|
|
672
|
+
extractedAnimations = [];
|
|
673
|
+
constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
|
|
674
|
+
this.fps = fps;
|
|
675
|
+
this.iouThreshold = iouThreshold;
|
|
676
|
+
this.animationThreshold = animationThreshold;
|
|
677
|
+
}
|
|
678
|
+
update(boxes, pairIndex) {
|
|
679
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
680
|
+
const matched = /* @__PURE__ */ new Set();
|
|
681
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
682
|
+
const box = boxes[bi];
|
|
683
|
+
let bestIoU = 0;
|
|
684
|
+
let bestRegionIdx = -1;
|
|
685
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
686
|
+
if (matched.has(ri)) continue;
|
|
687
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
688
|
+
if (iou > bestIoU) {
|
|
689
|
+
bestIoU = iou;
|
|
690
|
+
bestRegionIdx = ri;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
694
|
+
const region = this.regions[bestRegionIdx];
|
|
695
|
+
const gap = pairIndex - region.lastSeen;
|
|
696
|
+
region.box = box;
|
|
697
|
+
region.consecutiveCount++;
|
|
698
|
+
region.lastSeen = pairIndex;
|
|
699
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
700
|
+
matched.add(bestRegionIdx);
|
|
701
|
+
if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
|
|
702
|
+
} else this.regions.push({
|
|
703
|
+
box,
|
|
704
|
+
consecutiveCount: 1,
|
|
705
|
+
firstSeen: pairIndex,
|
|
706
|
+
lastSeen: pairIndex,
|
|
707
|
+
weight: 1
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
|
|
711
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
712
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
713
|
+
}
|
|
714
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
715
|
+
const region = this.regions[i];
|
|
716
|
+
if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
|
|
717
|
+
}
|
|
718
|
+
this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
|
|
719
|
+
return animationIndices;
|
|
720
|
+
}
|
|
721
|
+
collectAnimation(region) {
|
|
722
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
723
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
724
|
+
this.extractedAnimations.push({
|
|
725
|
+
type: "loading_spinner",
|
|
726
|
+
boundingBox: region.box,
|
|
727
|
+
startFrameId: region.firstSeen,
|
|
728
|
+
endFrameId: region.lastSeen,
|
|
729
|
+
durationMs
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
flushAndGetAnimations() {
|
|
734
|
+
for (const region of this.regions) this.collectAnimation(region);
|
|
735
|
+
this.regions = [];
|
|
736
|
+
return this.extractedAnimations;
|
|
737
|
+
}
|
|
738
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
739
|
+
if (boxIndex >= boxes.length) return 0;
|
|
740
|
+
const box = boxes[boxIndex];
|
|
741
|
+
let maxWeight = 0;
|
|
742
|
+
for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
|
|
743
|
+
if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
|
|
744
|
+
}
|
|
745
|
+
return maxWeight;
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/core/analyzer/index.ts
|
|
752
|
+
var init_analyzer = __esmMin((() => {
|
|
753
|
+
init_analyzer$1();
|
|
754
|
+
init_dbscan();
|
|
755
|
+
}));
|
|
756
|
+
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/core/utils/metadata/build-video-metadata.ts
|
|
759
|
+
/**
|
|
760
|
+
* Read output dimensions and build consistent video and animation metadata.
|
|
761
|
+
* JPEG finalization does not resize, so the source dimensions match the output.
|
|
762
|
+
* @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
|
|
763
|
+
* @param selected Selected frames in output order; the first candidate is the fallback.
|
|
764
|
+
* @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
|
|
765
|
+
* @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
|
|
766
|
+
* @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
|
|
767
|
+
*/
|
|
768
|
+
async function buildVideoMetadata(ctx, selected, analysisResolution) {
|
|
769
|
+
const firstFrame = selected[0] ?? ctx.frames[0];
|
|
770
|
+
const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
|
|
771
|
+
const width = dimensions?.width ?? 0;
|
|
772
|
+
const height = dimensions?.height ?? 0;
|
|
773
|
+
const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
|
|
774
|
+
const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
|
|
775
|
+
const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
|
|
776
|
+
const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
|
|
777
|
+
return {
|
|
778
|
+
video: {
|
|
779
|
+
originalDurationMs: Math.round(duration * 1e3),
|
|
780
|
+
fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
|
|
781
|
+
resolution: {
|
|
782
|
+
width,
|
|
783
|
+
height
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
animations: (ctx.animations ?? []).map((animation) => {
|
|
787
|
+
const box = animation.boundingBox;
|
|
788
|
+
const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
|
|
789
|
+
const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
|
|
790
|
+
return {
|
|
791
|
+
...animation,
|
|
792
|
+
boundingBox: {
|
|
793
|
+
x,
|
|
794
|
+
y,
|
|
795
|
+
width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
|
|
796
|
+
height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
})
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
var init_build_video_metadata = __esmMin((() => {}));
|
|
591
803
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/core/constants/workspace-layout.ts
|
|
806
|
+
function getTempWorkspaceDir(sessionId) {
|
|
807
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
808
|
+
}
|
|
809
|
+
var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
|
|
810
|
+
var init_workspace_layout = __esmMin((() => {
|
|
811
|
+
APP_NAME = "scene-sieve";
|
|
812
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
813
|
+
TEMP_BASE_DIR = tmpdir();
|
|
814
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
815
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
816
|
+
}));
|
|
817
|
+
|
|
818
|
+
//#endregion
|
|
819
|
+
//#region src/core/utils/filesystem/paths.ts
|
|
596
820
|
async function ensureDir(dirPath) {
|
|
597
|
-
|
|
821
|
+
await mkdir(dirPath, { recursive: true });
|
|
598
822
|
}
|
|
599
823
|
async function fileExists(filePath) {
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
824
|
+
try {
|
|
825
|
+
await stat(filePath);
|
|
826
|
+
return true;
|
|
827
|
+
} catch {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
606
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
|
|
833
|
+
* so paths like ~/Desktop/foo depend on process.cwd() and can produce
|
|
834
|
+
* different results when run from different directories.
|
|
835
|
+
*/
|
|
607
836
|
function expandTilde(p) {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
return p;
|
|
837
|
+
if (p === "~") return homedir();
|
|
838
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2));
|
|
839
|
+
return p;
|
|
613
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* Resolve path to absolute. Expands ~ to homedir first so that the result
|
|
843
|
+
* does not depend on process.cwd().
|
|
844
|
+
*/
|
|
614
845
|
function resolveAbsolute(p) {
|
|
615
|
-
|
|
846
|
+
return resolve(expandTilde(p));
|
|
616
847
|
}
|
|
848
|
+
/**
|
|
849
|
+
* Derive default output directory name from input file path.
|
|
850
|
+
* e.g., /path/to/video.mp4 -> /path/to/video_scenes
|
|
851
|
+
*/
|
|
617
852
|
function deriveOutputPath(inputPath) {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
var init_paths = __esm({
|
|
623
|
-
"src/utils/paths.ts"() {
|
|
624
|
-
"use strict";
|
|
625
|
-
}
|
|
626
|
-
});
|
|
853
|
+
return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
|
|
854
|
+
}
|
|
855
|
+
var init_paths = __esmMin((() => {}));
|
|
627
856
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region src/core/extractor/extractor.ts
|
|
859
|
+
/**
|
|
860
|
+
* Extract frames from video/GIF using FFmpeg.
|
|
861
|
+
* @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
|
|
862
|
+
* @returns Extracted candidates, or the unchanged input array in frames mode.
|
|
863
|
+
* @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
|
|
864
|
+
*/
|
|
635
865
|
async function extractFrames(ctx) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
"-q:v",
|
|
692
|
-
"2",
|
|
693
|
-
outputPattern
|
|
694
|
-
]);
|
|
695
|
-
return buildFrameList(outputDir, duration);
|
|
866
|
+
if (ctx.options.mode === "frames") {
|
|
867
|
+
ctx.effectiveFps = 1;
|
|
868
|
+
return ctx.frames;
|
|
869
|
+
}
|
|
870
|
+
const framesDir = join(ctx.workspacePath, "frames");
|
|
871
|
+
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
872
|
+
if (!inputPath) throw new Error("inputPath is required for frame extraction");
|
|
873
|
+
if (!await fileExists(inputPath)) throw new Error(`Input file not found: ${inputPath}`);
|
|
874
|
+
const metadata = await getVideoMetadata(inputPath).catch((err) => {
|
|
875
|
+
logger.debug(`ffprobe failed: ${err.message}`);
|
|
876
|
+
return null;
|
|
877
|
+
});
|
|
878
|
+
if (!metadata || !metadata.format) throw new Error(`Could not read file metadata: ${inputPath}`);
|
|
879
|
+
const formatName = metadata.format.format_name ?? "";
|
|
880
|
+
const duration = parseFloat(metadata.format.duration ?? "0");
|
|
881
|
+
if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
|
|
882
|
+
logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
|
|
883
|
+
await ensureDir(framesDir);
|
|
884
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
885
|
+
let effectiveFps = fps;
|
|
886
|
+
if (duration > 0) {
|
|
887
|
+
const fpsCap = frameLimit / duration;
|
|
888
|
+
effectiveFps = Math.min(fps, fpsCap);
|
|
889
|
+
logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
|
|
890
|
+
}
|
|
891
|
+
ctx.effectiveFps = effectiveFps;
|
|
892
|
+
ctx.sourceDurationSec = duration;
|
|
893
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
|
|
894
|
+
ctx.emitProgress(100);
|
|
895
|
+
logger.debug(`Extracted ${frames.length} frames`);
|
|
896
|
+
return frames;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Write scaled JPEG candidates through the bundled FFmpeg runtime.
|
|
900
|
+
* @param inputPath Readable video input.
|
|
901
|
+
* @param outputDir Existing frame directory.
|
|
902
|
+
* @param fps Positive effective sampling frequency.
|
|
903
|
+
* @param scale Output image height.
|
|
904
|
+
* @param frameLimit Maximum number of output frames.
|
|
905
|
+
* @returns Candidates with local output-grid timestamps; rejects on extraction failure.
|
|
906
|
+
*/
|
|
907
|
+
async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
|
|
908
|
+
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
909
|
+
await execa(ffmpegPath, [
|
|
910
|
+
"-i",
|
|
911
|
+
inputPath,
|
|
912
|
+
"-vf",
|
|
913
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
914
|
+
"-q:v",
|
|
915
|
+
"2",
|
|
916
|
+
"-frames:v",
|
|
917
|
+
String(frameLimit),
|
|
918
|
+
outputPattern
|
|
919
|
+
]);
|
|
920
|
+
return buildFrameList(outputDir, fps);
|
|
696
921
|
}
|
|
697
922
|
async function getVideoMetadata(inputPath) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
923
|
+
const { stdout } = await execa(path, [
|
|
924
|
+
"-v",
|
|
925
|
+
"quiet",
|
|
926
|
+
"-print_format",
|
|
927
|
+
"json",
|
|
928
|
+
"-show_format",
|
|
929
|
+
"-show_streams",
|
|
930
|
+
inputPath
|
|
931
|
+
]);
|
|
932
|
+
return JSON.parse(stdout);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Read sorted JPEG paths and attach local output-grid times.
|
|
936
|
+
* @param framesDir Extracted frame directory; filesystem errors propagate.
|
|
937
|
+
* @param effectiveFps Positive frequency used by the fps filter.
|
|
938
|
+
* @returns Zero-based candidates without a segment seek offset.
|
|
939
|
+
*/
|
|
940
|
+
async function buildFrameList(framesDir, effectiveFps) {
|
|
941
|
+
const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
|
|
942
|
+
if (jpgFiles.length === 0) return [];
|
|
943
|
+
return map(jpgFiles, (file, index) => ({
|
|
944
|
+
id: index,
|
|
945
|
+
timestamp: index / effectiveFps,
|
|
946
|
+
extractPath: join(framesDir, file)
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Extract frames from a specific time range of a video using FFmpeg.
|
|
951
|
+
* Uses input seeking (-ss before -i) for fast seek + -t for duration.
|
|
952
|
+
*
|
|
953
|
+
* @param inputPath - Path to the video file
|
|
954
|
+
* @param outputDir - Directory to write extracted frames
|
|
955
|
+
* @param fps - Frames per second for extraction
|
|
956
|
+
* @param scale - Height scale for vision analysis
|
|
957
|
+
* @param startTime - Start time in seconds
|
|
958
|
+
* @param duration - Duration in seconds to extract
|
|
959
|
+
* @param frameLimit - Positive output limit; defaults to the range's grid capacity
|
|
960
|
+
* @returns Array of FrameNode with segment-local timestamps (starting from 0)
|
|
961
|
+
*/
|
|
962
|
+
async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
|
|
963
|
+
const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
|
|
964
|
+
await execa(ffmpegPath, [
|
|
965
|
+
"-ss",
|
|
966
|
+
String(startTime),
|
|
967
|
+
"-i",
|
|
968
|
+
inputPath,
|
|
969
|
+
"-t",
|
|
970
|
+
String(duration),
|
|
971
|
+
"-vf",
|
|
972
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
973
|
+
"-q:v",
|
|
974
|
+
"2",
|
|
975
|
+
"-frames:v",
|
|
976
|
+
String(frameLimit),
|
|
977
|
+
outputPattern
|
|
978
|
+
]);
|
|
979
|
+
return buildFrameList(outputDir, fps);
|
|
980
|
+
}
|
|
981
|
+
var init_extractor$1 = __esmMin((() => {
|
|
982
|
+
init_workspace_layout();
|
|
983
|
+
init_logger();
|
|
984
|
+
init_paths();
|
|
985
|
+
}));
|
|
986
|
+
|
|
987
|
+
//#endregion
|
|
988
|
+
//#region src/core/extractor/index.ts
|
|
989
|
+
var init_extractor = __esmMin((() => {
|
|
990
|
+
init_extractor$1();
|
|
991
|
+
}));
|
|
992
|
+
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/core/input-resolver/validation/validate-options.ts
|
|
995
|
+
/**
|
|
996
|
+
* Reject invalid numeric options before defaults or pipeline effects are applied.
|
|
997
|
+
* @param options - Supplied options; omitted numeric fields use pipeline defaults.
|
|
998
|
+
* @returns Nothing when all supplied numeric fields satisfy their contracts.
|
|
999
|
+
* @throws An input error naming the invalid option and its received value.
|
|
1000
|
+
*/
|
|
1001
|
+
function validateOptions(options) {
|
|
1002
|
+
for (const [name, min, max, integer, exclusiveMin, requirement] of [
|
|
1003
|
+
[
|
|
1004
|
+
"count",
|
|
1005
|
+
1,
|
|
1006
|
+
Infinity,
|
|
1007
|
+
true,
|
|
1008
|
+
false,
|
|
1009
|
+
"an integer >= 1"
|
|
1010
|
+
],
|
|
1011
|
+
[
|
|
1012
|
+
"threshold",
|
|
1013
|
+
0,
|
|
1014
|
+
1,
|
|
1015
|
+
false,
|
|
1016
|
+
true,
|
|
1017
|
+
"in range (0, 1] and finite"
|
|
1018
|
+
],
|
|
1019
|
+
[
|
|
1020
|
+
"fps",
|
|
1021
|
+
0,
|
|
1022
|
+
Infinity,
|
|
1023
|
+
false,
|
|
1024
|
+
true,
|
|
1025
|
+
"finite and > 0"
|
|
1026
|
+
],
|
|
1027
|
+
[
|
|
1028
|
+
"maxFrames",
|
|
1029
|
+
2,
|
|
1030
|
+
Infinity,
|
|
1031
|
+
true,
|
|
1032
|
+
false,
|
|
1033
|
+
"an integer >= 2"
|
|
1034
|
+
],
|
|
1035
|
+
[
|
|
1036
|
+
"scale",
|
|
1037
|
+
16,
|
|
1038
|
+
Infinity,
|
|
1039
|
+
true,
|
|
1040
|
+
false,
|
|
1041
|
+
"an integer >= 16"
|
|
1042
|
+
],
|
|
1043
|
+
[
|
|
1044
|
+
"quality",
|
|
1045
|
+
1,
|
|
1046
|
+
100,
|
|
1047
|
+
true,
|
|
1048
|
+
false,
|
|
1049
|
+
"an integer in range [1, 100]"
|
|
1050
|
+
],
|
|
1051
|
+
[
|
|
1052
|
+
"iouThreshold",
|
|
1053
|
+
0,
|
|
1054
|
+
1,
|
|
1055
|
+
false,
|
|
1056
|
+
false,
|
|
1057
|
+
"finite and in range [0, 1]"
|
|
1058
|
+
],
|
|
1059
|
+
[
|
|
1060
|
+
"animationThreshold",
|
|
1061
|
+
1,
|
|
1062
|
+
Infinity,
|
|
1063
|
+
true,
|
|
1064
|
+
false,
|
|
1065
|
+
"an integer >= 1"
|
|
1066
|
+
],
|
|
1067
|
+
[
|
|
1068
|
+
"maxSegmentDuration",
|
|
1069
|
+
0,
|
|
1070
|
+
Infinity,
|
|
1071
|
+
false,
|
|
1072
|
+
true,
|
|
1073
|
+
"finite and > 0"
|
|
1074
|
+
],
|
|
1075
|
+
[
|
|
1076
|
+
"concurrency",
|
|
1077
|
+
1,
|
|
1078
|
+
Infinity,
|
|
1079
|
+
true,
|
|
1080
|
+
false,
|
|
1081
|
+
"an integer >= 1"
|
|
1082
|
+
]
|
|
1083
|
+
]) {
|
|
1084
|
+
const value = options[name];
|
|
1085
|
+
if (value === void 0) continue;
|
|
1086
|
+
if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
var init_validate_options = __esmMin((() => {}));
|
|
746
1090
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
import { join as join3 } from "path";
|
|
750
|
-
import { map as map3 } from "@winglet/common-utils";
|
|
751
|
-
import sharp2 from "sharp";
|
|
1091
|
+
//#endregion
|
|
1092
|
+
//#region src/core/workspace/workspace.ts
|
|
752
1093
|
async function createWorkspace(sessionId) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1094
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
1095
|
+
await ensureDir(join(workspacePath, "frames"));
|
|
1096
|
+
await ensureDir(join(workspacePath, "output"));
|
|
1097
|
+
return workspacePath;
|
|
757
1098
|
}
|
|
758
1099
|
async function finalizeOutput(ctx, selectedFrames) {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
await rm(outputPath, { recursive: true, force: true });
|
|
803
|
-
await rename(stagingDir, outputPath);
|
|
804
|
-
return outputFiles;
|
|
1100
|
+
const stagingDir = join(ctx.workspacePath, "output");
|
|
1101
|
+
const outputPath = ctx.options.outputPath;
|
|
1102
|
+
const quality = ctx.options.quality;
|
|
1103
|
+
const outputFiles = [];
|
|
1104
|
+
const framesMetadata = [];
|
|
1105
|
+
const totalFramesCount = ctx.frames.length;
|
|
1106
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
1107
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
1108
|
+
const frame = selectedFrames[i];
|
|
1109
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
1110
|
+
const destPath = join(stagingDir, fileName);
|
|
1111
|
+
await sharp(frame.extractPath).jpeg({
|
|
1112
|
+
quality,
|
|
1113
|
+
mozjpeg: true
|
|
1114
|
+
}).toFile(destPath);
|
|
1115
|
+
outputFiles.push(join(outputPath, fileName));
|
|
1116
|
+
framesMetadata.push({
|
|
1117
|
+
step: i + 1,
|
|
1118
|
+
fileName,
|
|
1119
|
+
frameId: frame.id + 1,
|
|
1120
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
|
|
1124
|
+
const metadata = {
|
|
1125
|
+
video,
|
|
1126
|
+
frames: framesMetadata,
|
|
1127
|
+
animations: map(animations, (anim) => ({
|
|
1128
|
+
...anim,
|
|
1129
|
+
startFrameId: anim.startFrameId + 1,
|
|
1130
|
+
endFrameId: anim.endFrameId + 1,
|
|
1131
|
+
durationMs: Math.round(anim.durationMs)
|
|
1132
|
+
}))
|
|
1133
|
+
};
|
|
1134
|
+
await writeFile(join(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
|
|
1135
|
+
outputFiles.push(join(outputPath, ".metadata.json"));
|
|
1136
|
+
await ensureDir(join(outputPath, ".."));
|
|
1137
|
+
await rm(outputPath, {
|
|
1138
|
+
recursive: true,
|
|
1139
|
+
force: true
|
|
1140
|
+
});
|
|
1141
|
+
await rename(stagingDir, outputPath);
|
|
1142
|
+
return outputFiles;
|
|
805
1143
|
}
|
|
806
1144
|
async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
String(segmentIndex)
|
|
811
|
-
);
|
|
812
|
-
await ensureDir(join3(segmentPath, "frames"));
|
|
813
|
-
return segmentPath;
|
|
1145
|
+
const segmentPath = join(parentWorkspacePath, "segments", String(segmentIndex));
|
|
1146
|
+
await ensureDir(join(segmentPath, "frames"));
|
|
1147
|
+
return segmentPath;
|
|
814
1148
|
}
|
|
815
1149
|
async function cleanupWorkspace(workspacePath) {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1150
|
+
if (!workspacePath) return;
|
|
1151
|
+
try {
|
|
1152
|
+
await rm(workspacePath, {
|
|
1153
|
+
recursive: true,
|
|
1154
|
+
force: true
|
|
1155
|
+
});
|
|
1156
|
+
} catch {}
|
|
821
1157
|
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Remove stale workspace directories left by previous interrupted runs.
|
|
1160
|
+
* Only deletes directories older than 1 hour to avoid removing active workspaces.
|
|
1161
|
+
*/
|
|
822
1162
|
async function cleanupStaleWorkspaces() {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1163
|
+
const entries = await readdir(TEMP_BASE_DIR);
|
|
1164
|
+
const now = Date.now();
|
|
1165
|
+
for (const entry of entries) {
|
|
1166
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
1167
|
+
const fullPath = join(TEMP_BASE_DIR, entry);
|
|
1168
|
+
try {
|
|
1169
|
+
const info = await stat(fullPath);
|
|
1170
|
+
if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) await rm(fullPath, {
|
|
1171
|
+
recursive: true,
|
|
1172
|
+
force: true
|
|
1173
|
+
});
|
|
1174
|
+
} catch {}
|
|
1175
|
+
}
|
|
836
1176
|
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Write a video buffer to a temp file in the workspace and return the path.
|
|
1179
|
+
* Used by 'buffer' input mode.
|
|
1180
|
+
*/
|
|
837
1181
|
async function writeInputBuffer(buffer, workspacePath) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1182
|
+
const inputDir = join(workspacePath, "input");
|
|
1183
|
+
await ensureDir(inputDir);
|
|
1184
|
+
const tempPath = join(inputDir, "input.mp4");
|
|
1185
|
+
await writeFile(tempPath, buffer);
|
|
1186
|
+
return tempPath;
|
|
843
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Write an array of frame Buffers as JPG files and return FrameNode[].
|
|
1190
|
+
* Used by 'frames' input mode.
|
|
1191
|
+
*/
|
|
844
1192
|
async function writeInputFrames(frames, workspacePath) {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1193
|
+
const framesDir = join(workspacePath, "frames");
|
|
1194
|
+
await ensureDir(framesDir);
|
|
1195
|
+
const frameNodes = [];
|
|
1196
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1197
|
+
const extractPath = join(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
|
|
1198
|
+
await writeFile(extractPath, frames[i]);
|
|
1199
|
+
frameNodes.push({
|
|
1200
|
+
id: i,
|
|
1201
|
+
timestamp: i,
|
|
1202
|
+
extractPath
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return frameNodes;
|
|
855
1206
|
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Read selected FrameNode files as Buffers with JPEG compression.
|
|
1209
|
+
* Used to return output buffers in 'buffer' and 'frames' modes.
|
|
1210
|
+
*/
|
|
856
1211
|
async function readFramesAsBuffers(frameNodes, quality) {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
)
|
|
862
|
-
);
|
|
1212
|
+
return Promise.all(map(frameNodes, (f) => sharp(f.extractPath).jpeg({
|
|
1213
|
+
quality,
|
|
1214
|
+
mozjpeg: true
|
|
1215
|
+
}).toBuffer()));
|
|
863
1216
|
}
|
|
864
1217
|
var STALE_THRESHOLD_MS;
|
|
865
|
-
var init_workspace =
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1218
|
+
var init_workspace$1 = __esmMin((() => {
|
|
1219
|
+
init_workspace_layout();
|
|
1220
|
+
init_paths();
|
|
1221
|
+
init_build_video_metadata();
|
|
1222
|
+
STALE_THRESHOLD_MS = 3600 * 1e3;
|
|
1223
|
+
}));
|
|
1224
|
+
|
|
1225
|
+
//#endregion
|
|
1226
|
+
//#region src/core/workspace/index.ts
|
|
1227
|
+
var init_workspace = __esmMin((() => {
|
|
1228
|
+
init_workspace$1();
|
|
1229
|
+
}));
|
|
873
1230
|
|
|
874
|
-
|
|
875
|
-
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/core/input-resolver/input-resolver.ts
|
|
1233
|
+
/**
|
|
1234
|
+
* Validate supplied options and resolve defaults and paths for the pipeline.
|
|
1235
|
+
* @param options - Mode-specific input and optional numeric settings.
|
|
1236
|
+
* @returns Complete pipeline settings with absolute file input paths.
|
|
1237
|
+
* @throws An input error if a supplied numeric setting is invalid.
|
|
1238
|
+
*/
|
|
876
1239
|
function resolveOptions(options) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
900
|
-
debug: options.debug ?? false,
|
|
901
|
-
maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
|
|
902
|
-
concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
|
|
903
|
-
};
|
|
1240
|
+
validateOptions(options);
|
|
1241
|
+
const mode = options.mode;
|
|
1242
|
+
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
1243
|
+
const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
|
|
1244
|
+
const threshold = options.threshold ?? .5;
|
|
1245
|
+
return {
|
|
1246
|
+
mode,
|
|
1247
|
+
inputPath,
|
|
1248
|
+
count: options.count ?? 20,
|
|
1249
|
+
threshold,
|
|
1250
|
+
pruneMode: "threshold-with-cap",
|
|
1251
|
+
outputPath,
|
|
1252
|
+
fps: options.fps ?? 5,
|
|
1253
|
+
maxFrames: options.maxFrames ?? 300,
|
|
1254
|
+
scale: options.scale ?? 720,
|
|
1255
|
+
quality: options.quality ?? 80,
|
|
1256
|
+
iouThreshold: options.iouThreshold ?? .9,
|
|
1257
|
+
animationThreshold: options.animationThreshold ?? 5,
|
|
1258
|
+
debug: options.debug ?? false,
|
|
1259
|
+
maxSegmentDuration: options.maxSegmentDuration ?? 300,
|
|
1260
|
+
concurrency: options.concurrency ?? 2
|
|
1261
|
+
};
|
|
904
1262
|
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Resolve the input source to a list of FrameNode[].
|
|
1265
|
+
*
|
|
1266
|
+
* - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
|
|
1267
|
+
* - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
|
|
1268
|
+
* - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
|
|
1269
|
+
* @param options - Input source; encoded frames must have matching dimensions.
|
|
1270
|
+
* @param workspacePath - Workspace receiving temporary input files.
|
|
1271
|
+
* @returns Frame nodes or a resolved video path for extraction.
|
|
1272
|
+
* @throws Propagates metadata or write errors and rejects mismatched frame sizes.
|
|
1273
|
+
*/
|
|
905
1274
|
async function resolveInput(options, workspacePath) {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1275
|
+
if (options.mode === "file") return {
|
|
1276
|
+
frames: [],
|
|
1277
|
+
resolvedInputPath: resolveAbsolute(options.inputPath)
|
|
1278
|
+
};
|
|
1279
|
+
if (options.mode === "buffer") return {
|
|
1280
|
+
frames: [],
|
|
1281
|
+
resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
|
|
1282
|
+
};
|
|
1283
|
+
if (options.mode === "frames") {
|
|
1284
|
+
let dimensions;
|
|
1285
|
+
for (const buffer of options.inputFrames) {
|
|
1286
|
+
const { width, height } = await sharp(buffer).metadata();
|
|
1287
|
+
if (dimensions && (width !== dimensions.width || height !== dimensions.height)) throw new Error(`inputFrames must be the same size (${dimensions.width}x${dimensions.height}), received: ${width}x${height}`);
|
|
1288
|
+
dimensions = {
|
|
1289
|
+
width,
|
|
1290
|
+
height
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
|
|
1294
|
+
}
|
|
1295
|
+
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
1296
|
+
}
|
|
1297
|
+
var init_input_resolver$1 = __esmMin((() => {
|
|
1298
|
+
init_pipeline_defaults();
|
|
1299
|
+
init_paths();
|
|
1300
|
+
init_validate_options();
|
|
1301
|
+
init_workspace();
|
|
1302
|
+
}));
|
|
1303
|
+
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/core/input-resolver/index.ts
|
|
1306
|
+
var init_input_resolver = __esmMin((() => {
|
|
1307
|
+
init_input_resolver$1();
|
|
1308
|
+
}));
|
|
933
1309
|
|
|
934
|
-
|
|
935
|
-
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/core/pruner/scoring/normalize-scores.ts
|
|
1312
|
+
/**
|
|
1313
|
+
* Find the first position whose score is at least the requested value.
|
|
1314
|
+
* @param sorted - Finite positive scores sorted in ascending order.
|
|
1315
|
+
* @param value - A finite positive score present in sorted.
|
|
1316
|
+
* @returns The first matching rank, including the first position of any tie.
|
|
1317
|
+
*/
|
|
1318
|
+
function lowerBound(sorted, value) {
|
|
1319
|
+
let low = 0;
|
|
1320
|
+
let high = sorted.length;
|
|
1321
|
+
while (low < high) {
|
|
1322
|
+
const mid = Math.floor((low + high) / 2);
|
|
1323
|
+
if (sorted[mid] < value) low = mid + 1;
|
|
1324
|
+
else high = mid;
|
|
1325
|
+
}
|
|
1326
|
+
return low;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
|
|
1330
|
+
*
|
|
1331
|
+
* This model combines two mathematical approaches to provide a stable "relative" threshold:
|
|
1332
|
+
*
|
|
1333
|
+
* 1. Logistic-Robust-Z (Intensity):
|
|
1334
|
+
* Calculates Z-scores using Median and Median Absolute Deviation (MAD).
|
|
1335
|
+
* Maps these to a sigmoid (logistic) curve. This suppresses noise (scores near median)
|
|
1336
|
+
* and highlights significant signals (outliers) without letting extreme outliers
|
|
1337
|
+
* crush other meaningful transitions.
|
|
1338
|
+
*
|
|
1339
|
+
* 2. CDF / Percentile Rank (Relative Position):
|
|
1340
|
+
* Maps each score to its percentile rank in the sequence. This ensures that 't'
|
|
1341
|
+
* always has a consistent meaning as a "relative rank" regardless of absolute values.
|
|
1342
|
+
*
|
|
1343
|
+
* The final score is a weighted sum (NORMALIZATION_ALPHA) of both.
|
|
1344
|
+
*
|
|
1345
|
+
* @param items - Array of items with scores to normalize
|
|
1346
|
+
* @returns normalized scores array (same length as input)
|
|
1347
|
+
*/
|
|
936
1348
|
function normalizeScores(items) {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
logisticZ,
|
|
971
|
-
(z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
|
|
972
|
-
);
|
|
973
|
-
}
|
|
974
|
-
var init_math = __esm({
|
|
975
|
-
"src/utils/math.ts"() {
|
|
976
|
-
"use strict";
|
|
977
|
-
init_constants();
|
|
978
|
-
}
|
|
979
|
-
});
|
|
1349
|
+
if (items.length === 0) return [];
|
|
1350
|
+
const safeScores = map(items, (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0);
|
|
1351
|
+
const positiveScores = filter(safeScores, (s) => s > 0);
|
|
1352
|
+
if (positiveScores.length === 0) return safeScores;
|
|
1353
|
+
const sorted = [...positiveScores].sort((a, b) => a - b);
|
|
1354
|
+
if (positiveScores.length <= 10) {
|
|
1355
|
+
const min = sorted[0];
|
|
1356
|
+
const max = sorted[sorted.length - 1];
|
|
1357
|
+
if (max === min) return map(safeScores, (s) => s > 0 ? 1 : 0);
|
|
1358
|
+
return map(safeScores, (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1)));
|
|
1359
|
+
}
|
|
1360
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1361
|
+
const absoluteDiffs = map(positiveScores, (v) => Math.abs(v - median));
|
|
1362
|
+
const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
|
|
1363
|
+
const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
|
|
1364
|
+
const logisticZ = map(safeScores, (s) => {
|
|
1365
|
+
if (s <= 0) return 0;
|
|
1366
|
+
if (scale === 0) return 1;
|
|
1367
|
+
const z = (s - median) / scale;
|
|
1368
|
+
return 1 / (1 + Math.exp(-3 * z));
|
|
1369
|
+
});
|
|
1370
|
+
const cdf = map(safeScores, (s) => {
|
|
1371
|
+
if (s <= 0) return 0;
|
|
1372
|
+
return lowerBound(sorted, s) / sorted.length;
|
|
1373
|
+
});
|
|
1374
|
+
return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
|
|
1375
|
+
}
|
|
1376
|
+
var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
|
|
1377
|
+
var init_normalize_scores = __esmMin((() => {
|
|
1378
|
+
NORMALIZATION_ALPHA = .4;
|
|
1379
|
+
NORMALIZATION_MAD_COEFFICIENT = 1.4826;
|
|
1380
|
+
NORMALIZATION_MIN_SAMPLE_SIZE = 10;
|
|
1381
|
+
}));
|
|
980
1382
|
|
|
981
|
-
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/core/pruner/heap/min-heap.ts
|
|
982
1385
|
var MinHeap;
|
|
983
|
-
var init_min_heap =
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
});
|
|
1386
|
+
var init_min_heap = __esmMin((() => {
|
|
1387
|
+
MinHeap = class {
|
|
1388
|
+
h = [];
|
|
1389
|
+
get size() {
|
|
1390
|
+
return this.h.length;
|
|
1391
|
+
}
|
|
1392
|
+
push(entry) {
|
|
1393
|
+
this.h.push(entry);
|
|
1394
|
+
this.siftUp(this.h.length - 1);
|
|
1395
|
+
}
|
|
1396
|
+
pop() {
|
|
1397
|
+
const n = this.h.length;
|
|
1398
|
+
if (n === 0) return void 0;
|
|
1399
|
+
const top = this.h[0];
|
|
1400
|
+
const last = this.h.pop();
|
|
1401
|
+
if (n > 1) {
|
|
1402
|
+
this.h[0] = last;
|
|
1403
|
+
this.siftDown(0);
|
|
1404
|
+
}
|
|
1405
|
+
return top;
|
|
1406
|
+
}
|
|
1407
|
+
siftUp(i) {
|
|
1408
|
+
while (i > 0) {
|
|
1409
|
+
const p = i - 1 >> 1;
|
|
1410
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
1411
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
1412
|
+
i = p;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
siftDown(i) {
|
|
1416
|
+
const n = this.h.length;
|
|
1417
|
+
for (;;) {
|
|
1418
|
+
let m = i;
|
|
1419
|
+
const l = 2 * i + 1;
|
|
1420
|
+
const r = 2 * i + 2;
|
|
1421
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
1422
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
1423
|
+
if (m === i) break;
|
|
1424
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
1425
|
+
i = m;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}));
|
|
1030
1430
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/core/pruner/pruner.ts
|
|
1433
|
+
/**
|
|
1434
|
+
* Edge-aware greedy merge with re-linking — O(N log N).
|
|
1435
|
+
*
|
|
1436
|
+
* 1. Build a doubly-linked list of frames
|
|
1437
|
+
* 2. Insert all edges into a min-heap
|
|
1438
|
+
* 3. Pop the lowest-score edge (most similar pair)
|
|
1439
|
+
* 4. Remove the later frame (tgtId), re-link neighbors
|
|
1440
|
+
* 5. Push synthetic edge with score = max(left, right)
|
|
1441
|
+
* 6. Repeat until surviving count === targetCount
|
|
1442
|
+
* 7. First and last frames are never removed (boundary preservation)
|
|
1443
|
+
*
|
|
1444
|
+
* Stale heap entries (involving removed frames) are lazily skipped on pop.
|
|
1445
|
+
*/
|
|
1033
1446
|
function pruneTo(graph, frames, targetCount) {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1447
|
+
if (frames.length <= targetCount) return new Set(map(frames, (f) => f.id));
|
|
1448
|
+
const prev = /* @__PURE__ */ new Map();
|
|
1449
|
+
const next = /* @__PURE__ */ new Map();
|
|
1450
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1451
|
+
if (i > 0) prev.set(frames[i].id, frames[i - 1].id);
|
|
1452
|
+
if (i < frames.length - 1) next.set(frames[i].id, frames[i + 1].id);
|
|
1453
|
+
}
|
|
1454
|
+
const edgeScore = /* @__PURE__ */ new Map();
|
|
1455
|
+
const heap = new MinHeap();
|
|
1456
|
+
for (const edge of graph) {
|
|
1457
|
+
edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
|
|
1458
|
+
heap.push({
|
|
1459
|
+
score: edge.score,
|
|
1460
|
+
srcId: edge.sourceId,
|
|
1461
|
+
tgtId: edge.targetId
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
const surviving = new Set(map(frames, (f) => f.id));
|
|
1465
|
+
const firstId = frames[0].id;
|
|
1466
|
+
const lastId = frames[frames.length - 1].id;
|
|
1467
|
+
while (surviving.size > targetCount && heap.size > 0) {
|
|
1468
|
+
const entry = heap.pop();
|
|
1469
|
+
if (!surviving.has(entry.srcId) || !surviving.has(entry.tgtId)) continue;
|
|
1470
|
+
const key = `${entry.srcId}:${entry.tgtId}`;
|
|
1471
|
+
if (edgeScore.get(key) !== entry.score) continue;
|
|
1472
|
+
if (entry.tgtId === firstId || entry.tgtId === lastId) continue;
|
|
1473
|
+
surviving.delete(entry.tgtId);
|
|
1474
|
+
edgeScore.delete(key);
|
|
1475
|
+
const tgtNext = next.get(entry.tgtId);
|
|
1476
|
+
if (tgtNext !== void 0) {
|
|
1477
|
+
const rightKey = `${entry.tgtId}:${tgtNext}`;
|
|
1478
|
+
const rightScore = edgeScore.get(rightKey) ?? 0;
|
|
1479
|
+
edgeScore.delete(rightKey);
|
|
1480
|
+
const newScore = Math.max(entry.score, rightScore);
|
|
1481
|
+
edgeScore.set(`${entry.srcId}:${tgtNext}`, newScore);
|
|
1482
|
+
heap.push({
|
|
1483
|
+
score: newScore,
|
|
1484
|
+
srcId: entry.srcId,
|
|
1485
|
+
tgtId: tgtNext
|
|
1486
|
+
});
|
|
1487
|
+
next.set(entry.srcId, tgtNext);
|
|
1488
|
+
prev.set(tgtNext, entry.srcId);
|
|
1489
|
+
} else next.delete(entry.srcId);
|
|
1490
|
+
prev.delete(entry.tgtId);
|
|
1491
|
+
next.delete(entry.tgtId);
|
|
1492
|
+
}
|
|
1493
|
+
return surviving;
|
|
1081
1494
|
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Non-Maximum Suppression (NMS) for consecutive edge runs.
|
|
1497
|
+
*
|
|
1498
|
+
* Consecutive edges share overlapping frames (edge i: frame i->i+1,
|
|
1499
|
+
* edge i+1: frame i+1->i+2), so consecutive passing edges indicate
|
|
1500
|
+
* the same visual transition region. This function groups consecutive
|
|
1501
|
+
* passing edge indices into "runs" and keeps all distinct peaks per run.
|
|
1502
|
+
*
|
|
1503
|
+
* Multi-peak detection: within each run, strict local maxima (score higher
|
|
1504
|
+
* than both neighbors) are identified. Each local maximum represents a
|
|
1505
|
+
* distinct visual transition. If no strict local maxima exist (plateau or
|
|
1506
|
+
* monotonic sequence), the global peak of the run is selected as fallback.
|
|
1507
|
+
*
|
|
1508
|
+
* Single-element runs are unaffected (isolated transitions preserved).
|
|
1509
|
+
*
|
|
1510
|
+
* @param graph - full ScoreEdge array (for targetId lookup)
|
|
1511
|
+
* @param passingIndices - edge indices that passed threshold filtering (sorted ascending)
|
|
1512
|
+
* @param normalizedScores - normalized score array (same length as graph)
|
|
1513
|
+
* @returns Set of targetIds to add to surviving set (one or more per run)
|
|
1514
|
+
*/
|
|
1082
1515
|
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
const idx = passingIndices[j];
|
|
1112
|
-
if (normalizedScores[idx] > normalizedScores[peakIdx]) {
|
|
1113
|
-
peakIdx = idx;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
result.add(graph[peakIdx].targetId);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
runStart = runEnd + 1;
|
|
1120
|
-
}
|
|
1121
|
-
return result;
|
|
1516
|
+
const result = /* @__PURE__ */ new Set();
|
|
1517
|
+
let runStart = 0;
|
|
1518
|
+
while (runStart < passingIndices.length) {
|
|
1519
|
+
let runEnd = runStart;
|
|
1520
|
+
while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) runEnd++;
|
|
1521
|
+
if (runEnd - runStart + 1 === 1) result.add(graph[passingIndices[runStart]].targetId);
|
|
1522
|
+
else {
|
|
1523
|
+
const peaks = [];
|
|
1524
|
+
for (let j = runStart; j <= runEnd; j++) {
|
|
1525
|
+
const idx = passingIndices[j];
|
|
1526
|
+
const score = normalizedScores[idx];
|
|
1527
|
+
const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
|
|
1528
|
+
const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
|
|
1529
|
+
if (score > prevScore && score > nextScore) peaks.push(idx);
|
|
1530
|
+
}
|
|
1531
|
+
if (peaks.length > 0) for (const peakIdx of peaks) result.add(graph[peakIdx].targetId);
|
|
1532
|
+
else {
|
|
1533
|
+
let peakIdx = passingIndices[runStart];
|
|
1534
|
+
for (let j = runStart + 1; j <= runEnd; j++) {
|
|
1535
|
+
const idx = passingIndices[j];
|
|
1536
|
+
if (normalizedScores[idx] > normalizedScores[peakIdx]) peakIdx = idx;
|
|
1537
|
+
}
|
|
1538
|
+
result.add(graph[peakIdx].targetId);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
runStart = runEnd + 1;
|
|
1542
|
+
}
|
|
1543
|
+
return result;
|
|
1122
1544
|
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Threshold-based pruning with NMS -- including normalization, O(N log N).
|
|
1547
|
+
*
|
|
1548
|
+
* 1. Scores are normalized to [0, 1] via percentile normalization.
|
|
1549
|
+
* 2. Edges with normalized score >= threshold are collected.
|
|
1550
|
+
* 3. Non-Maximum Suppression groups consecutive passing edges and keeps
|
|
1551
|
+
* only the peak per run, preventing near-duplicate frame selection
|
|
1552
|
+
* from a single visual transition.
|
|
1553
|
+
*
|
|
1554
|
+
* First and last frames are always preserved (boundary protection).
|
|
1555
|
+
*/
|
|
1123
1556
|
function pruneByThreshold(graph, frames, threshold) {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1135
|
-
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
1136
|
-
for (const id of nmsTargets) {
|
|
1137
|
-
surviving.add(id);
|
|
1138
|
-
}
|
|
1139
|
-
return surviving;
|
|
1557
|
+
if (frames.length === 0) return /* @__PURE__ */ new Set();
|
|
1558
|
+
const surviving = /* @__PURE__ */ new Set();
|
|
1559
|
+
surviving.add(frames[0].id);
|
|
1560
|
+
surviving.add(frames[frames.length - 1].id);
|
|
1561
|
+
const normalized = normalizeScores(graph);
|
|
1562
|
+
const passingIndices = [];
|
|
1563
|
+
for (let i = 0; i < graph.length; i++) if (normalized[i] >= threshold) passingIndices.push(i);
|
|
1564
|
+
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
1565
|
+
for (const id of nmsTargets) surviving.add(id);
|
|
1566
|
+
return surviving;
|
|
1140
1567
|
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Combined threshold + count pruning -- 2-stage pipeline.
|
|
1570
|
+
*
|
|
1571
|
+
* Stage 1: pruneByThreshold -- keep all frames with normalized score >= threshold
|
|
1572
|
+
* Stage 2: if result exceeds maxCount, rebuild subgraph with synthetic edges
|
|
1573
|
+
* (min-score over each gap) and apply pruneTo on the surviving subset
|
|
1574
|
+
*
|
|
1575
|
+
* Edge reconstruction: for consecutive survivors A, B with removed frames
|
|
1576
|
+
* [x1, x2, ...] between them, the synthetic edge score is:
|
|
1577
|
+
* min(score(A->x1), score(x1->x2), ..., score(xN->B))
|
|
1578
|
+
* This preserves the "weakest link" semantics.
|
|
1579
|
+
*/
|
|
1141
1580
|
function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
});
|
|
1175
|
-
}
|
|
1176
|
-
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1177
|
-
}
|
|
1178
|
-
var init_pruner = __esm({
|
|
1179
|
-
"src/core/pruner.ts"() {
|
|
1180
|
-
"use strict";
|
|
1181
|
-
init_math();
|
|
1182
|
-
init_min_heap();
|
|
1183
|
-
}
|
|
1184
|
-
});
|
|
1581
|
+
const thresholdSurvivors = pruneByThreshold(graph, frames, threshold);
|
|
1582
|
+
if (thresholdSurvivors.size <= maxCount) return thresholdSurvivors;
|
|
1583
|
+
const survivingFrames = filter(frames, (f) => thresholdSurvivors.has(f.id));
|
|
1584
|
+
const idToOrigIdx = /* @__PURE__ */ new Map();
|
|
1585
|
+
for (let i = 0; i < frames.length; i++) idToOrigIdx.set(frames[i].id, i);
|
|
1586
|
+
const edgeLookup = /* @__PURE__ */ new Map();
|
|
1587
|
+
for (const e of graph) edgeLookup.set(`${e.sourceId}:${e.targetId}`, e.score);
|
|
1588
|
+
const syntheticEdges = [];
|
|
1589
|
+
for (let i = 0; i < survivingFrames.length - 1; i++) {
|
|
1590
|
+
const srcSurvivor = survivingFrames[i];
|
|
1591
|
+
const tgtSurvivor = survivingFrames[i + 1];
|
|
1592
|
+
const srcOrigIdx = idToOrigIdx.get(srcSurvivor.id);
|
|
1593
|
+
const tgtOrigIdx = idToOrigIdx.get(tgtSurvivor.id);
|
|
1594
|
+
let minScore = Infinity;
|
|
1595
|
+
for (let j = srcOrigIdx; j < tgtOrigIdx; j++) {
|
|
1596
|
+
const fromId = frames[j].id;
|
|
1597
|
+
const toId = frames[j + 1].id;
|
|
1598
|
+
const score = edgeLookup.get(`${fromId}:${toId}`) ?? 0;
|
|
1599
|
+
if (score < minScore) minScore = score;
|
|
1600
|
+
}
|
|
1601
|
+
syntheticEdges.push({
|
|
1602
|
+
sourceId: srcSurvivor.id,
|
|
1603
|
+
targetId: tgtSurvivor.id,
|
|
1604
|
+
score: minScore === Infinity ? 0 : minScore
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1608
|
+
}
|
|
1609
|
+
var init_pruner$1 = __esmMin((() => {
|
|
1610
|
+
init_normalize_scores();
|
|
1611
|
+
init_min_heap();
|
|
1612
|
+
}));
|
|
1185
1613
|
|
|
1186
|
-
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/core/pruner/index.ts
|
|
1616
|
+
var init_pruner = __esmMin((() => {
|
|
1617
|
+
init_pruner$1();
|
|
1618
|
+
}));
|
|
1619
|
+
|
|
1620
|
+
//#endregion
|
|
1621
|
+
//#region src/core/segmenter/scheduling/concurrency.ts
|
|
1622
|
+
/**
|
|
1623
|
+
* Creates a concurrency limiter that runs at most `limit` tasks in parallel.
|
|
1624
|
+
* Lightweight replacement for p-limit to avoid external dependency.
|
|
1625
|
+
*/
|
|
1187
1626
|
function concurrencyLimit(limit) {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
}
|
|
1204
|
-
var init_concurrency = __esm({
|
|
1205
|
-
"src/utils/concurrency.ts"() {
|
|
1206
|
-
"use strict";
|
|
1207
|
-
}
|
|
1208
|
-
});
|
|
1627
|
+
limit = Math.max(1, limit);
|
|
1628
|
+
let active = 0;
|
|
1629
|
+
const queue = [];
|
|
1630
|
+
return async (fn) => {
|
|
1631
|
+
while (active >= limit) await new Promise((resolve) => queue.push(resolve));
|
|
1632
|
+
active++;
|
|
1633
|
+
try {
|
|
1634
|
+
return await fn();
|
|
1635
|
+
} finally {
|
|
1636
|
+
active--;
|
|
1637
|
+
queue.shift()?.();
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
var init_concurrency = __esmMin((() => {}));
|
|
1209
1642
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1643
|
+
//#endregion
|
|
1644
|
+
//#region src/core/segmenter/segmenter.ts
|
|
1645
|
+
/**
|
|
1646
|
+
* Determine whether segmentation should be used.
|
|
1647
|
+
* Returns false for frames mode and GIF files.
|
|
1648
|
+
* Actual duration check happens inside runSegmentedPipeline after metadata fetch.
|
|
1649
|
+
*/
|
|
1214
1650
|
function shouldSegment(resolvedOptions, originalOptions) {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1651
|
+
if (resolvedOptions.mode === "frames") return false;
|
|
1652
|
+
if (originalOptions.mode === "file") {
|
|
1653
|
+
if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
|
|
1654
|
+
}
|
|
1655
|
+
return true;
|
|
1220
1656
|
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Partition the global extraction grid into nonempty logical segments.
|
|
1659
|
+
* @param totalDuration Positive source duration in seconds.
|
|
1660
|
+
* @param maxSegmentDuration Positive logical segment width in seconds.
|
|
1661
|
+
* @param maxFrames Candidate budget, defensively raised to at least two.
|
|
1662
|
+
* @param fps Positive requested sampling frequency.
|
|
1663
|
+
* @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
|
|
1664
|
+
*/
|
|
1221
1665
|
function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
});
|
|
1272
|
-
}
|
|
1273
|
-
const totalAllocated = segments.reduce(
|
|
1274
|
-
(sum, s) => sum + s.allocatedFrames,
|
|
1275
|
-
0
|
|
1276
|
-
);
|
|
1277
|
-
if (totalAllocated > maxFrames) {
|
|
1278
|
-
segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
|
|
1279
|
-
}
|
|
1280
|
-
return segments;
|
|
1666
|
+
const frameLimit = Math.max(2, maxFrames);
|
|
1667
|
+
const effectiveFps = Math.min(fps, frameLimit / totalDuration);
|
|
1668
|
+
if (totalDuration <= maxSegmentDuration) return [{
|
|
1669
|
+
index: 0,
|
|
1670
|
+
startTime: 0,
|
|
1671
|
+
endTime: totalDuration,
|
|
1672
|
+
duration: totalDuration,
|
|
1673
|
+
allocatedFrames: frameLimit,
|
|
1674
|
+
effectiveFps,
|
|
1675
|
+
overlapBefore: 0,
|
|
1676
|
+
overlapAfter: 0,
|
|
1677
|
+
extractStartTime: 0,
|
|
1678
|
+
extractDuration: totalDuration
|
|
1679
|
+
}];
|
|
1680
|
+
const segments = [];
|
|
1681
|
+
for (let slot = 0; slot < frameLimit; slot++) {
|
|
1682
|
+
const timestamp = slot / effectiveFps;
|
|
1683
|
+
if (timestamp >= totalDuration) break;
|
|
1684
|
+
const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
|
|
1685
|
+
const previous = segments[segments.length - 1];
|
|
1686
|
+
if (previous?.startTime === startTime) {
|
|
1687
|
+
previous.allocatedFrames++;
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
|
|
1691
|
+
segments.push({
|
|
1692
|
+
index: segments.length,
|
|
1693
|
+
startTime,
|
|
1694
|
+
endTime,
|
|
1695
|
+
duration: endTime - startTime,
|
|
1696
|
+
allocatedFrames: 1,
|
|
1697
|
+
effectiveFps,
|
|
1698
|
+
overlapBefore: 0,
|
|
1699
|
+
overlapAfter: 0,
|
|
1700
|
+
extractStartTime: timestamp,
|
|
1701
|
+
extractDuration: 0
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
let firstSlot = 0;
|
|
1705
|
+
for (const segment of segments) {
|
|
1706
|
+
const nextSlot = firstSlot + segment.allocatedFrames;
|
|
1707
|
+
segment.overlapBefore = segment.index > 0 ? 1 : 0;
|
|
1708
|
+
segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
|
|
1709
|
+
segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
|
|
1710
|
+
segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
|
|
1711
|
+
segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
|
|
1712
|
+
firstSlot = nextSlot;
|
|
1713
|
+
}
|
|
1714
|
+
return segments;
|
|
1281
1715
|
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Collect all frames from every segment, adjusting timestamps by extractStartTime.
|
|
1718
|
+
*/
|
|
1282
1719
|
function collectAllFrames(segmentResults) {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
localId: frame.id
|
|
1294
|
-
});
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
return allFrames;
|
|
1720
|
+
const allFrames = [];
|
|
1721
|
+
for (const result of segmentResults) for (const frame of result.frames) allFrames.push({
|
|
1722
|
+
frame: {
|
|
1723
|
+
...frame,
|
|
1724
|
+
timestamp: frame.timestamp + result.segment.extractStartTime
|
|
1725
|
+
},
|
|
1726
|
+
segmentIndex: result.segment.index,
|
|
1727
|
+
localId: frame.id
|
|
1728
|
+
});
|
|
1729
|
+
return allFrames;
|
|
1298
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Sort frames in place and alias overlap duplicates to the first survivor.
|
|
1733
|
+
* @param frames Collected entries whose segment index and local ID identify a frame.
|
|
1734
|
+
* @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
|
|
1735
|
+
* @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
|
|
1736
|
+
*/
|
|
1299
1737
|
function deduplicateFrames(frames, effectiveFps) {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
return {
|
|
1319
|
-
id: globalId,
|
|
1320
|
-
timestamp: entry.frame.timestamp,
|
|
1321
|
-
extractPath: entry.frame.extractPath
|
|
1322
|
-
};
|
|
1323
|
-
});
|
|
1324
|
-
return { frames, globalIdMap };
|
|
1738
|
+
frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
|
|
1739
|
+
const dupThreshold = 1 / (effectiveFps * 2);
|
|
1740
|
+
const unique = [];
|
|
1741
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1742
|
+
for (const entry of frames) {
|
|
1743
|
+
if (unique.length > 0) {
|
|
1744
|
+
const last = unique[unique.length - 1];
|
|
1745
|
+
if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
|
|
1746
|
+
aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
unique.push(entry);
|
|
1751
|
+
}
|
|
1752
|
+
return {
|
|
1753
|
+
unique,
|
|
1754
|
+
aliases
|
|
1755
|
+
};
|
|
1325
1756
|
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Assign sequential global IDs and retain duplicate local IDs as aliases.
|
|
1759
|
+
* @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
|
|
1760
|
+
* @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
|
|
1761
|
+
* @returns Remapped frames and a global ID lookup covering survivors and duplicates.
|
|
1762
|
+
*/
|
|
1763
|
+
function remapFrameIds(uniqueFrames, aliases) {
|
|
1764
|
+
const globalIdMap = /* @__PURE__ */ new Map();
|
|
1765
|
+
const frames = uniqueFrames.map((entry, globalId) => {
|
|
1766
|
+
globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
|
|
1767
|
+
return {
|
|
1768
|
+
id: globalId,
|
|
1769
|
+
timestamp: entry.frame.timestamp,
|
|
1770
|
+
extractPath: entry.frame.extractPath
|
|
1771
|
+
};
|
|
1772
|
+
});
|
|
1773
|
+
for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
|
|
1774
|
+
return {
|
|
1775
|
+
frames,
|
|
1776
|
+
globalIdMap
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
|
|
1781
|
+
* @param segmentResults Segment-local edges in encounter order.
|
|
1782
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
1783
|
+
* @returns One edge per surviving directed pair without changing its score.
|
|
1784
|
+
*/
|
|
1326
1785
|
function remapEdges(segmentResults, globalIdMap) {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
targetId: newTargetId,
|
|
1353
|
-
score: edge.score
|
|
1354
|
-
});
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
return edges;
|
|
1786
|
+
const edges = [];
|
|
1787
|
+
const edgeMap = /* @__PURE__ */ new Map();
|
|
1788
|
+
for (const result of segmentResults) for (const edge of result.edges) {
|
|
1789
|
+
const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
|
|
1790
|
+
const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
|
|
1791
|
+
if (newSourceId === void 0 || newTargetId === void 0) continue;
|
|
1792
|
+
if (newSourceId === newTargetId) continue;
|
|
1793
|
+
const edgeKey = `${newSourceId}-${newTargetId}`;
|
|
1794
|
+
const existingIdx = edgeMap.get(edgeKey);
|
|
1795
|
+
if (existingIdx !== void 0) {
|
|
1796
|
+
if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
|
|
1797
|
+
sourceId: newSourceId,
|
|
1798
|
+
targetId: newTargetId,
|
|
1799
|
+
score: edge.score
|
|
1800
|
+
};
|
|
1801
|
+
} else {
|
|
1802
|
+
edgeMap.set(edgeKey, edges.length);
|
|
1803
|
+
edges.push({
|
|
1804
|
+
sourceId: newSourceId,
|
|
1805
|
+
targetId: newTargetId,
|
|
1806
|
+
score: edge.score
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return edges;
|
|
1359
1811
|
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
|
|
1814
|
+
* @param segmentResults Segment-local tracker entries in encounter order.
|
|
1815
|
+
* @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
|
|
1816
|
+
* @returns One animation per directed pair with its original tracker duration and metadata.
|
|
1817
|
+
*/
|
|
1360
1818
|
function remapAnimations(segmentResults, globalIdMap) {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
return animations;
|
|
1819
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1820
|
+
for (const result of segmentResults) for (const anim of result.animations) {
|
|
1821
|
+
const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
|
|
1822
|
+
const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
|
|
1823
|
+
if (newStartId === void 0 || newEndId === void 0) continue;
|
|
1824
|
+
if (newStartId === newEndId) continue;
|
|
1825
|
+
const animationKey = `${newStartId}-${newEndId}`;
|
|
1826
|
+
if (animations.has(animationKey)) continue;
|
|
1827
|
+
animations.set(animationKey, {
|
|
1828
|
+
...anim,
|
|
1829
|
+
startFrameId: newStartId,
|
|
1830
|
+
endFrameId: newEndId
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
return [...animations.values()];
|
|
1379
1834
|
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Merge multiple segment results into a single unified frame/edge/animation set.
|
|
1837
|
+
* @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
|
|
1838
|
+
* @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
|
|
1839
|
+
* Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
|
|
1840
|
+
*/
|
|
1380
1841
|
function mergeSegmentFrames(segmentResults) {
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1842
|
+
if (segmentResults.length === 0) return {
|
|
1843
|
+
frames: [],
|
|
1844
|
+
edges: [],
|
|
1845
|
+
animations: [],
|
|
1846
|
+
analysisResolution: {
|
|
1847
|
+
width: 0,
|
|
1848
|
+
height: 0
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
const effectiveFps = segmentResults[0].segment.effectiveFps;
|
|
1852
|
+
const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
|
|
1853
|
+
const { frames, globalIdMap } = remapFrameIds(unique, aliases);
|
|
1854
|
+
return {
|
|
1855
|
+
frames,
|
|
1856
|
+
edges: remapEdges(segmentResults, globalIdMap),
|
|
1857
|
+
animations: remapAnimations(segmentResults, globalIdMap),
|
|
1858
|
+
analysisResolution: segmentResults[0].analysisResolution
|
|
1859
|
+
};
|
|
1391
1860
|
}
|
|
1392
1861
|
function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1862
|
+
return {
|
|
1863
|
+
options: {
|
|
1864
|
+
...resolvedOptions,
|
|
1865
|
+
fps: segment.effectiveFps,
|
|
1866
|
+
maxFrames: segment.allocatedFrames
|
|
1867
|
+
},
|
|
1868
|
+
workspacePath: segmentWorkspacePath,
|
|
1869
|
+
effectiveFps: segment.effectiveFps,
|
|
1870
|
+
frames,
|
|
1871
|
+
graph: [],
|
|
1872
|
+
status: "ANALYZING",
|
|
1873
|
+
emitProgress: onProgress
|
|
1874
|
+
};
|
|
1405
1875
|
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Extract frames for a single segment and analyze them.
|
|
1878
|
+
* Each segment uses an isolated workspace directory.
|
|
1879
|
+
*/
|
|
1406
1880
|
async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
1427
|
-
return { segment, frames, edges, animations };
|
|
1881
|
+
const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
|
|
1882
|
+
if (frames.length < 2) return {
|
|
1883
|
+
segment,
|
|
1884
|
+
frames,
|
|
1885
|
+
edges: [],
|
|
1886
|
+
animations: [],
|
|
1887
|
+
analysisResolution: {
|
|
1888
|
+
width: 0,
|
|
1889
|
+
height: 0
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
|
|
1893
|
+
return {
|
|
1894
|
+
segment,
|
|
1895
|
+
frames,
|
|
1896
|
+
edges,
|
|
1897
|
+
animations,
|
|
1898
|
+
analysisResolution
|
|
1899
|
+
};
|
|
1428
1900
|
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Full segmented pipeline: metadata → plan → parallel extract+analyze → merge → prune → finalize.
|
|
1903
|
+
* Called from runPipeline when shouldSegment() returns true.
|
|
1904
|
+
*/
|
|
1429
1905
|
async function runSegmentedPipeline(options, resolvedOptions) {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
workspacePath: mainWorkspace,
|
|
1506
|
-
frames,
|
|
1507
|
-
graph: edges,
|
|
1508
|
-
animations,
|
|
1509
|
-
status: "FINALIZING",
|
|
1510
|
-
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1511
|
-
};
|
|
1512
|
-
let outputFiles = [];
|
|
1513
|
-
let outputBuffers;
|
|
1514
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1515
|
-
outputBuffers = await readFramesAsBuffers(
|
|
1516
|
-
prunedFrames,
|
|
1517
|
-
resolvedOptions.quality
|
|
1518
|
-
);
|
|
1519
|
-
} else {
|
|
1520
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1521
|
-
}
|
|
1522
|
-
options.onProgress?.("FINALIZING", 100);
|
|
1523
|
-
logger.success(
|
|
1524
|
-
`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
|
|
1525
|
-
);
|
|
1526
|
-
return {
|
|
1527
|
-
success: true,
|
|
1528
|
-
originalFramesCount: frames.length,
|
|
1529
|
-
prunedFramesCount: prunedFrames.length,
|
|
1530
|
-
outputFiles,
|
|
1531
|
-
outputBuffers,
|
|
1532
|
-
animations,
|
|
1533
|
-
video: {
|
|
1534
|
-
originalDurationMs: totalDuration * 1e3,
|
|
1535
|
-
fps: resolvedOptions.fps,
|
|
1536
|
-
resolution: {
|
|
1537
|
-
width: resolvedOptions.scale,
|
|
1538
|
-
height: Math.round(resolvedOptions.scale * 9 / 16)
|
|
1539
|
-
}
|
|
1540
|
-
},
|
|
1541
|
-
executionTimeMs: Date.now() - pipelineStart
|
|
1542
|
-
};
|
|
1543
|
-
} catch (error) {
|
|
1544
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
1545
|
-
logger.error(`Segmented pipeline failed: ${err.message}`);
|
|
1546
|
-
throw err;
|
|
1547
|
-
} finally {
|
|
1548
|
-
if (!resolvedOptions.debug) {
|
|
1549
|
-
await cleanupWorkspace(mainWorkspace);
|
|
1550
|
-
} else {
|
|
1551
|
-
logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
var init_segmenter = __esm({
|
|
1556
|
-
"src/core/segmenter.ts"() {
|
|
1557
|
-
"use strict";
|
|
1558
|
-
init_concurrency();
|
|
1559
|
-
init_logger();
|
|
1560
|
-
init_analyzer();
|
|
1561
|
-
init_extractor();
|
|
1562
|
-
init_input_resolver();
|
|
1563
|
-
init_pruner();
|
|
1564
|
-
init_workspace();
|
|
1565
|
-
}
|
|
1566
|
-
});
|
|
1567
|
-
|
|
1568
|
-
// src/core/orchestrator.ts
|
|
1569
|
-
var orchestrator_exports = {};
|
|
1570
|
-
__export(orchestrator_exports, {
|
|
1571
|
-
runPipeline: () => runPipeline
|
|
1572
|
-
});
|
|
1573
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
1574
|
-
import { filter as filter6 } from "@winglet/common-utils";
|
|
1575
|
-
async function runPipeline(options) {
|
|
1576
|
-
const debug = options.debug ?? false;
|
|
1577
|
-
if (debug) setDebugMode(true);
|
|
1578
|
-
const resolvedOptions = resolveOptions(options);
|
|
1579
|
-
if (shouldSegment(resolvedOptions, options)) {
|
|
1580
|
-
return runSegmentedPipeline(options, resolvedOptions);
|
|
1581
|
-
}
|
|
1582
|
-
const startTime = Date.now();
|
|
1583
|
-
const sessionId = randomUUID2();
|
|
1584
|
-
const ctx = {
|
|
1585
|
-
options: resolvedOptions,
|
|
1586
|
-
workspacePath: "",
|
|
1587
|
-
frames: [],
|
|
1588
|
-
graph: [],
|
|
1589
|
-
status: "INIT",
|
|
1590
|
-
emitProgress: (percent) => {
|
|
1591
|
-
if (options.onProgress && ctx.status !== "INIT" && ctx.status !== "SUCCESS" && ctx.status !== "FAILED") {
|
|
1592
|
-
options.onProgress(ctx.status, percent);
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
};
|
|
1596
|
-
try {
|
|
1597
|
-
ctx.workspacePath = await createWorkspace(sessionId);
|
|
1598
|
-
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1599
|
-
ctx.status = "EXTRACTING";
|
|
1600
|
-
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(
|
|
1601
|
-
options,
|
|
1602
|
-
ctx.workspacePath
|
|
1603
|
-
);
|
|
1604
|
-
if (resolvedOptions.mode === "frames") {
|
|
1605
|
-
ctx.frames = resolvedFrames;
|
|
1606
|
-
} else {
|
|
1607
|
-
const extractCtx = {
|
|
1608
|
-
...ctx,
|
|
1609
|
-
options: {
|
|
1610
|
-
...resolvedOptions,
|
|
1611
|
-
inputPath: resolvedInputPath
|
|
1612
|
-
}
|
|
1613
|
-
};
|
|
1614
|
-
ctx.frames = await extractFrames(extractCtx);
|
|
1615
|
-
}
|
|
1616
|
-
ctx.emitProgress(100);
|
|
1617
|
-
ctx.status = "ANALYZING";
|
|
1618
|
-
const { edges, animations } = await analyzeFrames(ctx);
|
|
1619
|
-
ctx.graph = edges;
|
|
1620
|
-
ctx.animations = animations;
|
|
1621
|
-
ctx.status = "PRUNING";
|
|
1622
|
-
const survivingIds = pruneByThresholdWithCap(
|
|
1623
|
-
ctx.graph,
|
|
1624
|
-
ctx.frames,
|
|
1625
|
-
resolvedOptions.threshold,
|
|
1626
|
-
resolvedOptions.count
|
|
1627
|
-
);
|
|
1628
|
-
const prunedFrames = filter6(ctx.frames, (f) => survivingIds.has(f.id));
|
|
1629
|
-
ctx.emitProgress(100);
|
|
1630
|
-
ctx.status = "FINALIZING";
|
|
1631
|
-
let outputFiles = [];
|
|
1632
|
-
let outputBuffers;
|
|
1633
|
-
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1634
|
-
outputBuffers = await readFramesAsBuffers(
|
|
1635
|
-
prunedFrames,
|
|
1636
|
-
resolvedOptions.quality
|
|
1637
|
-
);
|
|
1638
|
-
ctx.emitProgress(100);
|
|
1639
|
-
} else {
|
|
1640
|
-
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1641
|
-
ctx.emitProgress(100);
|
|
1642
|
-
}
|
|
1643
|
-
ctx.status = "SUCCESS";
|
|
1644
|
-
logger.success(
|
|
1645
|
-
`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`
|
|
1646
|
-
);
|
|
1647
|
-
return {
|
|
1648
|
-
success: true,
|
|
1649
|
-
originalFramesCount: ctx.frames.length,
|
|
1650
|
-
prunedFramesCount: prunedFrames.length,
|
|
1651
|
-
outputFiles,
|
|
1652
|
-
outputBuffers,
|
|
1653
|
-
animations: ctx.animations,
|
|
1654
|
-
video: {
|
|
1655
|
-
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1656
|
-
fps: ctx.options.fps,
|
|
1657
|
-
resolution: {
|
|
1658
|
-
width: ctx.options.scale,
|
|
1659
|
-
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1660
|
-
}
|
|
1661
|
-
},
|
|
1662
|
-
executionTimeMs: Date.now() - startTime
|
|
1663
|
-
};
|
|
1664
|
-
} catch (error) {
|
|
1665
|
-
ctx.status = "FAILED";
|
|
1666
|
-
ctx.error = error instanceof Error ? error : new Error(String(error));
|
|
1667
|
-
logger.error(`Pipeline failed: ${ctx.error.message}`);
|
|
1668
|
-
throw ctx.error;
|
|
1669
|
-
} finally {
|
|
1670
|
-
if (!resolvedOptions.debug) {
|
|
1671
|
-
await cleanupWorkspace(ctx.workspacePath);
|
|
1672
|
-
} else {
|
|
1673
|
-
logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
var init_orchestrator = __esm({
|
|
1678
|
-
"src/core/orchestrator.ts"() {
|
|
1679
|
-
"use strict";
|
|
1680
|
-
init_logger();
|
|
1681
|
-
init_analyzer();
|
|
1682
|
-
init_extractor();
|
|
1683
|
-
init_input_resolver();
|
|
1684
|
-
init_pruner();
|
|
1685
|
-
init_segmenter();
|
|
1686
|
-
init_workspace();
|
|
1687
|
-
}
|
|
1688
|
-
});
|
|
1689
|
-
|
|
1690
|
-
// src/cli.ts
|
|
1691
|
-
import { createRequire as createRequire2 } from "module";
|
|
1692
|
-
|
|
1693
|
-
// ../shared/src/respond.ts
|
|
1694
|
-
function respond(command, data, startTime, version2) {
|
|
1695
|
-
const response = {
|
|
1696
|
-
ok: true,
|
|
1697
|
-
command,
|
|
1698
|
-
data,
|
|
1699
|
-
meta: {
|
|
1700
|
-
version: version2,
|
|
1701
|
-
durationMs: Date.now() - startTime,
|
|
1702
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1703
|
-
}
|
|
1704
|
-
};
|
|
1705
|
-
process.stdout.write(JSON.stringify(response) + "\n");
|
|
1706
|
-
}
|
|
1707
|
-
function respondError(command, code, message, startTime, version2, details) {
|
|
1708
|
-
const response = {
|
|
1709
|
-
ok: false,
|
|
1710
|
-
command,
|
|
1711
|
-
error: { code, message, ...details !== void 0 ? { details } : {} },
|
|
1712
|
-
meta: {
|
|
1713
|
-
version: version2,
|
|
1714
|
-
durationMs: Date.now() - startTime,
|
|
1715
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1716
|
-
}
|
|
1717
|
-
};
|
|
1718
|
-
process.stdout.write(JSON.stringify(response) + "\n");
|
|
1719
|
-
process.exitCode = 1;
|
|
1906
|
+
const pipelineStart = Date.now();
|
|
1907
|
+
const sessionId = randomUUID();
|
|
1908
|
+
let mainWorkspace = "";
|
|
1909
|
+
try {
|
|
1910
|
+
mainWorkspace = await createWorkspace(sessionId);
|
|
1911
|
+
logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
|
|
1912
|
+
const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
|
|
1913
|
+
const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
|
|
1914
|
+
if (!inputPath) throw new Error("No input path available for segmented pipeline");
|
|
1915
|
+
const metadata = await getVideoMetadata(inputPath);
|
|
1916
|
+
const totalDuration = parseFloat(metadata.format?.duration ?? "0");
|
|
1917
|
+
if (totalDuration <= 0) throw new Error(`Invalid video duration: ${totalDuration}`);
|
|
1918
|
+
logger.debug(`Video duration: ${totalDuration}s`);
|
|
1919
|
+
const segments = computeSegmentPlan(totalDuration, resolvedOptions.maxSegmentDuration, resolvedOptions.maxFrames, resolvedOptions.fps);
|
|
1920
|
+
logger.debug(`Segment plan: ${segments.length} segments`);
|
|
1921
|
+
const limit = concurrencyLimit(resolvedOptions.concurrency);
|
|
1922
|
+
const segmentProgresses = new Array(segments.length).fill(0);
|
|
1923
|
+
const weights = map(segments, (s) => s.duration / totalDuration);
|
|
1924
|
+
const emitOverallProgress = (phase) => {
|
|
1925
|
+
if (!options.onProgress) return;
|
|
1926
|
+
const overall = weights.reduce((sum, w, i) => sum + w * (segmentProgresses[i] ?? 0), 0);
|
|
1927
|
+
options.onProgress(phase, Math.min(100, overall));
|
|
1928
|
+
};
|
|
1929
|
+
options.onProgress?.("EXTRACTING", 0);
|
|
1930
|
+
const results = await Promise.all(map(segments, (segment) => limit(async () => {
|
|
1931
|
+
const segWorkspace = await createSegmentWorkspace(mainWorkspace, segment.index);
|
|
1932
|
+
return await processSegment(inputPath, segment, segWorkspace, resolvedOptions, (percent) => {
|
|
1933
|
+
segmentProgresses[segment.index] = percent;
|
|
1934
|
+
emitOverallProgress("ANALYZING");
|
|
1935
|
+
});
|
|
1936
|
+
})));
|
|
1937
|
+
options.onProgress?.("ANALYZING", 100);
|
|
1938
|
+
const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
|
|
1939
|
+
logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
|
|
1940
|
+
options.onProgress?.("PRUNING", 0);
|
|
1941
|
+
const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
1942
|
+
const prunedFrames = filter(frames, (f) => survivingIds.has(f.id));
|
|
1943
|
+
options.onProgress?.("PRUNING", 100);
|
|
1944
|
+
options.onProgress?.("FINALIZING", 0);
|
|
1945
|
+
const ctx = {
|
|
1946
|
+
options: resolvedOptions,
|
|
1947
|
+
effectiveFps: segments[0]?.effectiveFps,
|
|
1948
|
+
sourceDurationSec: totalDuration,
|
|
1949
|
+
analysisResolution,
|
|
1950
|
+
workspacePath: mainWorkspace,
|
|
1951
|
+
frames,
|
|
1952
|
+
graph: edges,
|
|
1953
|
+
animations,
|
|
1954
|
+
status: "FINALIZING",
|
|
1955
|
+
emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
|
|
1956
|
+
};
|
|
1957
|
+
let outputFiles = [];
|
|
1958
|
+
let outputBuffers;
|
|
1959
|
+
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
1960
|
+
else outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1961
|
+
options.onProgress?.("FINALIZING", 100);
|
|
1962
|
+
logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
|
|
1963
|
+
const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
1964
|
+
return {
|
|
1965
|
+
success: true,
|
|
1966
|
+
originalFramesCount: frames.length,
|
|
1967
|
+
prunedFramesCount: prunedFrames.length,
|
|
1968
|
+
outputFiles,
|
|
1969
|
+
outputBuffers,
|
|
1970
|
+
...outputMetadata,
|
|
1971
|
+
executionTimeMs: Date.now() - pipelineStart
|
|
1972
|
+
};
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1975
|
+
logger.error(`Segmented pipeline failed: ${err.message}`);
|
|
1976
|
+
throw err;
|
|
1977
|
+
} finally {
|
|
1978
|
+
if (!resolvedOptions.debug) await cleanupWorkspace(mainWorkspace);
|
|
1979
|
+
else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
|
|
1980
|
+
}
|
|
1720
1981
|
}
|
|
1982
|
+
var init_segmenter$1 = __esmMin((() => {
|
|
1983
|
+
init_concurrency();
|
|
1984
|
+
init_logger();
|
|
1985
|
+
init_analyzer();
|
|
1986
|
+
init_build_video_metadata();
|
|
1987
|
+
init_extractor();
|
|
1988
|
+
init_input_resolver();
|
|
1989
|
+
init_pruner();
|
|
1990
|
+
init_workspace();
|
|
1991
|
+
}));
|
|
1721
1992
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
import { Box as Box2, Text as Text3, useApp } from "ink";
|
|
1728
|
-
import { render } from "ink";
|
|
1729
|
-
import React, { useEffect, useState } from "react";
|
|
1730
|
-
|
|
1731
|
-
// src/components/PhaseStep.tsx
|
|
1732
|
-
import { Box, Text as Text2 } from "ink";
|
|
1733
|
-
import Spinner from "ink-spinner";
|
|
1734
|
-
|
|
1735
|
-
// src/components/ProgressBar.tsx
|
|
1736
|
-
import { Text } from "ink";
|
|
1737
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1738
|
-
var ProgressBar = ({
|
|
1739
|
-
percent,
|
|
1740
|
-
width = 30
|
|
1741
|
-
}) => {
|
|
1742
|
-
const clamped = Math.max(0, Math.min(100, percent));
|
|
1743
|
-
const filled = Math.round(width * (clamped / 100));
|
|
1744
|
-
const empty = width - filled;
|
|
1745
|
-
return /* @__PURE__ */ jsxs(Text, { children: [
|
|
1746
|
-
/* @__PURE__ */ jsx(Text, { color: "green", children: "\u2588".repeat(filled) }),
|
|
1747
|
-
/* @__PURE__ */ jsx(Text, { color: "gray", children: "\u2591".repeat(empty) }),
|
|
1748
|
-
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1749
|
-
" ",
|
|
1750
|
-
clamped,
|
|
1751
|
-
"%"
|
|
1752
|
-
] })
|
|
1753
|
-
] });
|
|
1754
|
-
};
|
|
1993
|
+
//#endregion
|
|
1994
|
+
//#region src/core/segmenter/index.ts
|
|
1995
|
+
var init_segmenter = __esmMin((() => {
|
|
1996
|
+
init_segmenter$1();
|
|
1997
|
+
}));
|
|
1755
1998
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
var
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1999
|
+
//#endregion
|
|
2000
|
+
//#region src/core/orchestrator/orchestrator.ts
|
|
2001
|
+
var orchestrator_exports = /* @__PURE__ */ __exportAll({ runPipeline: () => runPipeline });
|
|
2002
|
+
async function runPipeline(options) {
|
|
2003
|
+
setDebugMode(options.debug ?? false);
|
|
2004
|
+
const resolvedOptions = resolveOptions(options);
|
|
2005
|
+
if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
|
|
2006
|
+
const startTime = Date.now();
|
|
2007
|
+
const sessionId = randomUUID();
|
|
2008
|
+
const ctx = {
|
|
2009
|
+
options: resolvedOptions,
|
|
2010
|
+
workspacePath: "",
|
|
2011
|
+
frames: [],
|
|
2012
|
+
graph: [],
|
|
2013
|
+
status: "INIT",
|
|
2014
|
+
emitProgress: (percent) => {
|
|
2015
|
+
if (options.onProgress && ctx.status !== "INIT" && ctx.status !== "SUCCESS" && ctx.status !== "FAILED") options.onProgress(ctx.status, percent);
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
try {
|
|
2019
|
+
ctx.workspacePath = await createWorkspace(sessionId);
|
|
2020
|
+
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
2021
|
+
ctx.status = "EXTRACTING";
|
|
2022
|
+
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
|
|
2023
|
+
if (resolvedOptions.mode === "frames") {
|
|
2024
|
+
ctx.frames = resolvedFrames;
|
|
2025
|
+
ctx.effectiveFps = 1;
|
|
2026
|
+
} else {
|
|
2027
|
+
const extractCtx = {
|
|
2028
|
+
...ctx,
|
|
2029
|
+
options: {
|
|
2030
|
+
...resolvedOptions,
|
|
2031
|
+
inputPath: resolvedInputPath
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
2035
|
+
ctx.effectiveFps = extractCtx.effectiveFps;
|
|
2036
|
+
ctx.sourceDurationSec = extractCtx.sourceDurationSec;
|
|
2037
|
+
}
|
|
2038
|
+
ctx.emitProgress(100);
|
|
2039
|
+
ctx.status = "ANALYZING";
|
|
2040
|
+
const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
|
|
2041
|
+
ctx.graph = edges;
|
|
2042
|
+
ctx.animations = animations;
|
|
2043
|
+
ctx.analysisResolution = analysisResolution;
|
|
2044
|
+
ctx.status = "PRUNING";
|
|
2045
|
+
const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
|
|
2046
|
+
const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
|
|
2047
|
+
ctx.emitProgress(100);
|
|
2048
|
+
ctx.status = "FINALIZING";
|
|
2049
|
+
let outputFiles = [];
|
|
2050
|
+
let outputBuffers;
|
|
2051
|
+
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
2052
|
+
outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
|
|
2053
|
+
ctx.emitProgress(100);
|
|
2054
|
+
} else {
|
|
2055
|
+
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
2056
|
+
ctx.emitProgress(100);
|
|
2057
|
+
}
|
|
2058
|
+
ctx.status = "SUCCESS";
|
|
2059
|
+
logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
|
|
2060
|
+
const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
|
|
2061
|
+
return {
|
|
2062
|
+
success: true,
|
|
2063
|
+
originalFramesCount: ctx.frames.length,
|
|
2064
|
+
prunedFramesCount: prunedFrames.length,
|
|
2065
|
+
outputFiles,
|
|
2066
|
+
outputBuffers,
|
|
2067
|
+
...metadata,
|
|
2068
|
+
executionTimeMs: Date.now() - startTime
|
|
2069
|
+
};
|
|
2070
|
+
} catch (error) {
|
|
2071
|
+
ctx.status = "FAILED";
|
|
2072
|
+
ctx.error = error instanceof Error ? error : new Error(String(error));
|
|
2073
|
+
logger.error(`Pipeline failed: ${ctx.error.message}`);
|
|
2074
|
+
throw ctx.error;
|
|
2075
|
+
} finally {
|
|
2076
|
+
if (!resolvedOptions.debug) await cleanupWorkspace(ctx.workspacePath);
|
|
2077
|
+
else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
var init_orchestrator = __esmMin((() => {
|
|
2081
|
+
init_logger();
|
|
2082
|
+
init_analyzer();
|
|
2083
|
+
init_build_video_metadata();
|
|
2084
|
+
init_extractor();
|
|
2085
|
+
init_input_resolver();
|
|
2086
|
+
init_pruner();
|
|
2087
|
+
init_segmenter();
|
|
2088
|
+
init_workspace();
|
|
2089
|
+
}));
|
|
1793
2090
|
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
2091
|
+
//#endregion
|
|
2092
|
+
//#region src/core/orchestrator/worker/run-in-worker.ts
|
|
2093
|
+
/**
|
|
2094
|
+
* Run the pipeline, choosing the best execution strategy:
|
|
2095
|
+
*
|
|
2096
|
+
* - Production (bundled .mjs): Worker thread — spinner never freezes
|
|
2097
|
+
* - Dev mode (tsx .ts): Main thread — simpler, spinner may stutter during CPU work
|
|
2098
|
+
* @param options - Serializable input and pipeline settings for this run.
|
|
2099
|
+
* @param onProgress - Receives worker progress updates.
|
|
2100
|
+
* @returns The pipeline result; settlement is unchanged by later exit events.
|
|
2101
|
+
* @throws Rejects worker errors or any worker exit before a result is received.
|
|
2102
|
+
*/
|
|
1798
2103
|
async function runPipelineInWorker(options, onProgress) {
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
}
|
|
1826
|
-
});
|
|
1827
|
-
});
|
|
2104
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
2105
|
+
if (!currentFile.endsWith(".mjs")) {
|
|
2106
|
+
const { runPipeline } = await Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports));
|
|
2107
|
+
return runPipeline({
|
|
2108
|
+
...options,
|
|
2109
|
+
onProgress
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
const workerPath = join(dirname(currentFile), "pipeline-worker.mjs");
|
|
2113
|
+
return new Promise((resolve, reject) => {
|
|
2114
|
+
const worker = new Worker(workerPath, { workerData: options });
|
|
2115
|
+
worker.on("message", (msg) => {
|
|
2116
|
+
if (msg.type === "progress" && msg.phase && msg.percent !== void 0) onProgress(msg.phase, msg.percent);
|
|
2117
|
+
else if (msg.type === "result") {
|
|
2118
|
+
resolve(msg.result);
|
|
2119
|
+
worker.terminate();
|
|
2120
|
+
} else if (msg.type === "error") {
|
|
2121
|
+
reject(new Error(msg.message));
|
|
2122
|
+
worker.terminate();
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
worker.on("error", reject);
|
|
2126
|
+
worker.on("exit", (code) => {
|
|
2127
|
+
reject(/* @__PURE__ */ new Error(`Worker exited with code ${code} without a result`));
|
|
2128
|
+
});
|
|
2129
|
+
});
|
|
1828
2130
|
}
|
|
1829
2131
|
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
WORKER_ERROR: "WORKER_ERROR",
|
|
1840
|
-
UNKNOWN: "UNKNOWN"
|
|
2132
|
+
//#endregion
|
|
2133
|
+
//#region src/cli/errors/classify-error.ts
|
|
2134
|
+
const SieveErrorCode = {
|
|
2135
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
2136
|
+
FILE_NOT_FOUND: "FILE_NOT_FOUND",
|
|
2137
|
+
INVALID_FORMAT: "INVALID_FORMAT",
|
|
2138
|
+
PIPELINE_ERROR: "PIPELINE_ERROR",
|
|
2139
|
+
WORKER_ERROR: "WORKER_ERROR",
|
|
2140
|
+
UNKNOWN: "UNKNOWN"
|
|
1841
2141
|
};
|
|
2142
|
+
/**
|
|
2143
|
+
* Classify pipeline errors for structured CLI responses.
|
|
2144
|
+
* @param error - Failure with a diagnostic message and optional filesystem code.
|
|
2145
|
+
* @returns The existing error code matching the failure.
|
|
2146
|
+
*/
|
|
1842
2147
|
function classifyError(error) {
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
return SieveErrorCode.WORKER_ERROR;
|
|
1850
|
-
} else {
|
|
1851
|
-
return SieveErrorCode.PIPELINE_ERROR;
|
|
1852
|
-
}
|
|
2148
|
+
const msg = error.message.toLowerCase();
|
|
2149
|
+
if (msg.includes("must be")) return SieveErrorCode.INVALID_INPUT;
|
|
2150
|
+
else if (error.code === "ENOENT" || msg.includes("not found")) return SieveErrorCode.FILE_NOT_FOUND;
|
|
2151
|
+
else if (msg.includes("no video stream") || msg.includes("invalid format")) return SieveErrorCode.INVALID_FORMAT;
|
|
2152
|
+
else if (msg.includes("worker")) return SieveErrorCode.WORKER_ERROR;
|
|
2153
|
+
else return SieveErrorCode.PIPELINE_ERROR;
|
|
1853
2154
|
}
|
|
1854
2155
|
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
]
|
|
2156
|
+
//#endregion
|
|
2157
|
+
//#region src/cli/commands/command-registry.ts
|
|
2158
|
+
const SIEVE_COMMAND = {
|
|
2159
|
+
name: "scene-sieve",
|
|
2160
|
+
description: "Extract key frames from video and GIF files",
|
|
2161
|
+
usage: "scene-sieve <input> [options]",
|
|
2162
|
+
arguments: [{
|
|
2163
|
+
name: "input",
|
|
2164
|
+
description: "Input video or GIF file path",
|
|
2165
|
+
required: true
|
|
2166
|
+
}],
|
|
2167
|
+
options: [
|
|
2168
|
+
{
|
|
2169
|
+
flag: "-n, --count <number>",
|
|
2170
|
+
description: "Max number of frames to keep (default: 20)",
|
|
2171
|
+
type: "number"
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
flag: "-t, --threshold <number>",
|
|
2175
|
+
description: "Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)",
|
|
2176
|
+
type: "number"
|
|
2177
|
+
},
|
|
2178
|
+
{
|
|
2179
|
+
flag: "-o, --output <path>",
|
|
2180
|
+
description: "Output directory path",
|
|
2181
|
+
type: "string"
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
flag: "--fps <number>",
|
|
2185
|
+
description: "Max FPS for frame extraction",
|
|
2186
|
+
type: "number",
|
|
2187
|
+
default: "5"
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
flag: "-mf, --max-frames <number>",
|
|
2191
|
+
description: "Max frames to extract (auto-reduces FPS for long videos)",
|
|
2192
|
+
type: "number",
|
|
2193
|
+
default: "300"
|
|
2194
|
+
},
|
|
2195
|
+
{
|
|
2196
|
+
flag: "-s, --scale <number>",
|
|
2197
|
+
description: "Scale size for vision analysis",
|
|
2198
|
+
type: "number",
|
|
2199
|
+
default: "720"
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
flag: "-q, --quality <number>",
|
|
2203
|
+
description: "JPEG output quality 1-100",
|
|
2204
|
+
type: "number",
|
|
2205
|
+
default: "80"
|
|
2206
|
+
},
|
|
2207
|
+
{
|
|
2208
|
+
flag: "-it, --iou-threshold <number>",
|
|
2209
|
+
description: "IoU threshold for animation tracking (0-1) (default: 0.9)",
|
|
2210
|
+
type: "number"
|
|
2211
|
+
},
|
|
2212
|
+
{
|
|
2213
|
+
flag: "-at, --anim-threshold <number>",
|
|
2214
|
+
description: "Min consecutive frames for animation (default: 5)",
|
|
2215
|
+
type: "number"
|
|
2216
|
+
},
|
|
2217
|
+
{
|
|
2218
|
+
flag: "--max-segment-duration <number>",
|
|
2219
|
+
description: "Max segment duration in seconds for long video splitting (default: 300)",
|
|
2220
|
+
type: "number"
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
flag: "--concurrency <number>",
|
|
2224
|
+
description: "Number of segments to process in parallel (default: 2)",
|
|
2225
|
+
type: "number"
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
flag: "--debug",
|
|
2229
|
+
description: "Enable debug mode (preserve temp workspace)",
|
|
2230
|
+
type: "boolean"
|
|
2231
|
+
},
|
|
2232
|
+
{
|
|
2233
|
+
flag: "--json",
|
|
2234
|
+
description: "Output structured JSON to stdout",
|
|
2235
|
+
type: "boolean"
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
flag: "--describe",
|
|
2239
|
+
description: "Output JSON schema of available options",
|
|
2240
|
+
type: "boolean"
|
|
2241
|
+
}
|
|
2242
|
+
],
|
|
2243
|
+
examples: [
|
|
2244
|
+
"scene-sieve video.mp4",
|
|
2245
|
+
"scene-sieve video.mp4 -n 10",
|
|
2246
|
+
"scene-sieve video.mp4 -t 0.3 -o ./output",
|
|
2247
|
+
"scene-sieve video.mp4 --json",
|
|
2248
|
+
"scene-sieve --describe"
|
|
2249
|
+
]
|
|
1950
2250
|
};
|
|
1951
2251
|
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2252
|
+
//#endregion
|
|
2253
|
+
//#region src/cli/options/parse-options.ts
|
|
2254
|
+
/**
|
|
2255
|
+
* Parse one complete decimal string without truncating fractional values.
|
|
2256
|
+
* @param value - Decimal CLI argument, optionally using an exponent.
|
|
2257
|
+
* @param integer - Whether the result must be an integer.
|
|
2258
|
+
* @returns The finite parsed number, or NaN for invalid input.
|
|
2259
|
+
*/
|
|
2260
|
+
function parseNumberStrict(value, integer = false) {
|
|
2261
|
+
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) return NaN;
|
|
2262
|
+
const number = Number(value);
|
|
2263
|
+
return Number.isFinite(number) && (!integer || Number.isInteger(number)) ? number : NaN;
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Convert CLI strings to pipeline values for subsequent range validation.
|
|
2267
|
+
* @param opts - Commander options with raw numeric strings.
|
|
2268
|
+
* @returns Typed settings, preserving invalid numeric input as NaN.
|
|
2269
|
+
*/
|
|
1956
2270
|
function parsePipelineOptions(opts) {
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
2271
|
+
return {
|
|
2272
|
+
...opts.threshold !== void 0 ? { threshold: parseNumberStrict(opts.threshold) } : {},
|
|
2273
|
+
...opts.count !== void 0 ? { count: parseNumberStrict(opts.count, true) } : {},
|
|
2274
|
+
outputPath: opts.output,
|
|
2275
|
+
fps: parseNumberStrict(opts.fps),
|
|
2276
|
+
maxFrames: parseNumberStrict(opts.maxFrames, true),
|
|
2277
|
+
scale: parseNumberStrict(opts.scale, true),
|
|
2278
|
+
quality: parseNumberStrict(opts.quality, true),
|
|
2279
|
+
iouThreshold: opts.iouThreshold !== void 0 ? parseNumberStrict(opts.iouThreshold) : void 0,
|
|
2280
|
+
animationThreshold: opts.animThreshold !== void 0 ? parseNumberStrict(opts.animThreshold, true) : void 0,
|
|
2281
|
+
maxSegmentDuration: opts.maxSegmentDuration !== void 0 ? parseNumberStrict(opts.maxSegmentDuration) : void 0,
|
|
2282
|
+
concurrency: opts.concurrency !== void 0 ? parseNumberStrict(opts.concurrency, true) : void 0,
|
|
2283
|
+
debug: opts.debug ?? false
|
|
2284
|
+
};
|
|
1971
2285
|
}
|
|
1972
2286
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2287
|
+
//#endregion
|
|
2288
|
+
//#region src/cli/commands/Sieve.tsx
|
|
2289
|
+
init_pipeline_defaults();
|
|
2290
|
+
init_orchestrator(), init_workspace$1();
|
|
2291
|
+
init_logger();
|
|
2292
|
+
const PHASE_DEFS = [
|
|
2293
|
+
{
|
|
2294
|
+
key: "INIT",
|
|
2295
|
+
label: "Initializing workspace",
|
|
2296
|
+
hasProgress: false
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
key: "EXTRACTING",
|
|
2300
|
+
label: "Extracting frames",
|
|
2301
|
+
hasProgress: false
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
key: "ANALYZING",
|
|
2305
|
+
label: "Analyzing frame similarity",
|
|
2306
|
+
hasProgress: true
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
key: "PRUNING",
|
|
2310
|
+
label: "Pruning similar frames",
|
|
2311
|
+
hasProgress: false
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
key: "FINALIZING",
|
|
2315
|
+
label: "Finalizing output",
|
|
2316
|
+
hasProgress: false
|
|
2317
|
+
}
|
|
1981
2318
|
];
|
|
1982
2319
|
function createInitialPhases() {
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2320
|
+
return PHASE_DEFS.map((def) => ({
|
|
2321
|
+
label: def.label,
|
|
2322
|
+
status: "pending",
|
|
2323
|
+
hasProgress: def.hasProgress,
|
|
2324
|
+
percent: 0
|
|
2325
|
+
}));
|
|
1989
2326
|
}
|
|
1990
2327
|
function phaseKeyToIndex(phase) {
|
|
1991
|
-
|
|
1992
|
-
}
|
|
1993
|
-
function registerSieveCommand(
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
result.animations.length,
|
|
2204
|
-
" animations (recorded in .metadata.json)"
|
|
2205
|
-
] }),
|
|
2206
|
-
props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
2207
|
-
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
2208
|
-
"Output: ",
|
|
2209
|
-
result.outputFiles[0]?.replace(/\/[^/]+$/, "/")
|
|
2210
|
-
] }),
|
|
2211
|
-
result.outputFiles.map((f, i) => /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
2212
|
-
" - ",
|
|
2213
|
-
f
|
|
2214
|
-
] }, i))
|
|
2215
|
-
] })
|
|
2216
|
-
] })
|
|
2217
|
-
] });
|
|
2328
|
+
return PHASE_DEFS.findIndex((d) => d.key === phase);
|
|
2329
|
+
}
|
|
2330
|
+
function registerSieveCommand(program, version) {
|
|
2331
|
+
const cmd = SIEVE_COMMAND;
|
|
2332
|
+
program.argument("<input>", cmd.arguments[0].description).option("-n, --count <number>", cmd.options.find((o) => o.flag.includes("--count")).description).option("-t, --threshold <number>", cmd.options.find((o) => o.flag.includes("--threshold")).description).option("-o, --output <path>", cmd.options.find((o) => o.flag.includes("--output")).description).option("--fps <number>", cmd.options.find((o) => o.flag.includes("--fps")).description, String(5)).option("-mf, --max-frames <number>", cmd.options.find((o) => o.flag.includes("--max-frames")).description, String(300)).option("-s, --scale <number>", cmd.options.find((o) => o.flag.includes("--scale")).description, String(720)).option("-q, --quality <number>", cmd.options.find((o) => o.flag.includes("--quality")).description, String(80)).option("-it, --iou-threshold <number>", cmd.options.find((o) => o.flag.includes("--iou-threshold")).description).option("-at, --anim-threshold <number>", cmd.options.find((o) => o.flag.includes("--anim-threshold")).description).option("--max-segment-duration <number>", cmd.options.find((o) => o.flag.includes("--max-segment-duration")).description).option("--concurrency <number>", cmd.options.find((o) => o.flag.includes("--concurrency")).description).option("--debug", cmd.options.find((o) => o.flag.includes("--debug")).description).option("--json", cmd.options.find((o) => o.flag.includes("--json")).description).option("--describe", cmd.options.find((o) => o.flag.includes("--describe")).description).action(async (input, opts) => {
|
|
2333
|
+
const parsed = parsePipelineOptions(opts);
|
|
2334
|
+
if (opts.json) {
|
|
2335
|
+
setJsonMode(true);
|
|
2336
|
+
const startTime = Date.now();
|
|
2337
|
+
if (!existsSync(input)) {
|
|
2338
|
+
respondError("extract", SieveErrorCode.FILE_NOT_FOUND, `File not found: ${input}`, startTime, version);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
try {
|
|
2342
|
+
const result = await runPipeline({
|
|
2343
|
+
mode: "file",
|
|
2344
|
+
inputPath: input,
|
|
2345
|
+
...parsed,
|
|
2346
|
+
onProgress: (phase, percent) => {
|
|
2347
|
+
process.stderr.write(JSON.stringify({
|
|
2348
|
+
phase,
|
|
2349
|
+
percent
|
|
2350
|
+
}) + "\n");
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
respond("extract", {
|
|
2354
|
+
success: result.success,
|
|
2355
|
+
originalFrames: result.originalFramesCount,
|
|
2356
|
+
selectedFrames: result.prunedFramesCount,
|
|
2357
|
+
outputFiles: result.outputFiles,
|
|
2358
|
+
animations: result.animations ?? [],
|
|
2359
|
+
video: result.video ?? null
|
|
2360
|
+
}, startTime, version);
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2363
|
+
respondError("extract", classifyError(err), err.message, startTime, version);
|
|
2364
|
+
}
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const { outputPath, ...viewOpts } = parsed;
|
|
2368
|
+
const { waitUntilExit } = render(React.createElement(SieveView, {
|
|
2369
|
+
input,
|
|
2370
|
+
...viewOpts,
|
|
2371
|
+
output: outputPath
|
|
2372
|
+
}));
|
|
2373
|
+
await waitUntilExit();
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
const SieveView = (props) => {
|
|
2377
|
+
const { exit } = useApp();
|
|
2378
|
+
const [phases, setPhases] = useState(createInitialPhases);
|
|
2379
|
+
const [result, setResult] = useState(null);
|
|
2380
|
+
const [error, setError] = useState(null);
|
|
2381
|
+
useEffect(() => {
|
|
2382
|
+
const phaseStartTimes = PHASE_DEFS.map(() => 0);
|
|
2383
|
+
let currentPhaseKey = "";
|
|
2384
|
+
(async () => {
|
|
2385
|
+
try {
|
|
2386
|
+
await cleanupStaleWorkspaces().catch(() => {});
|
|
2387
|
+
phaseStartTimes[0] = Date.now();
|
|
2388
|
+
setPhases((prev) => {
|
|
2389
|
+
const next = [...prev];
|
|
2390
|
+
next[0] = {
|
|
2391
|
+
...next[0],
|
|
2392
|
+
status: "running"
|
|
2393
|
+
};
|
|
2394
|
+
return next;
|
|
2395
|
+
});
|
|
2396
|
+
const res = await runPipelineInWorker({
|
|
2397
|
+
mode: "file",
|
|
2398
|
+
inputPath: props.input,
|
|
2399
|
+
...props.threshold !== void 0 ? { threshold: props.threshold } : {},
|
|
2400
|
+
...props.count !== void 0 ? { count: props.count } : {},
|
|
2401
|
+
outputPath: props.output,
|
|
2402
|
+
fps: props.fps,
|
|
2403
|
+
maxFrames: props.maxFrames,
|
|
2404
|
+
scale: props.scale,
|
|
2405
|
+
quality: props.quality,
|
|
2406
|
+
iouThreshold: props.iouThreshold,
|
|
2407
|
+
animationThreshold: props.animationThreshold,
|
|
2408
|
+
maxSegmentDuration: props.maxSegmentDuration,
|
|
2409
|
+
concurrency: props.concurrency,
|
|
2410
|
+
debug: props.debug
|
|
2411
|
+
}, (phase, percent) => {
|
|
2412
|
+
const phaseIdx = phaseKeyToIndex(phase);
|
|
2413
|
+
if (phaseIdx < 0) return;
|
|
2414
|
+
if (phase !== currentPhaseKey) {
|
|
2415
|
+
const now = Date.now();
|
|
2416
|
+
currentPhaseKey = phase;
|
|
2417
|
+
phaseStartTimes[phaseIdx] = now;
|
|
2418
|
+
setPhases((prev) => {
|
|
2419
|
+
const next = [...prev];
|
|
2420
|
+
for (let i = 0; i < next.length; i++) if (i < phaseIdx) {
|
|
2421
|
+
if (next[i].status !== "done") next[i] = {
|
|
2422
|
+
...next[i],
|
|
2423
|
+
status: "done",
|
|
2424
|
+
percent: 100,
|
|
2425
|
+
durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
|
|
2426
|
+
};
|
|
2427
|
+
} else if (i === phaseIdx) next[i] = {
|
|
2428
|
+
...next[i],
|
|
2429
|
+
status: "running",
|
|
2430
|
+
percent: 0
|
|
2431
|
+
};
|
|
2432
|
+
return next;
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
setPhases((prev) => {
|
|
2436
|
+
const next = [...prev];
|
|
2437
|
+
if (next[phaseIdx].status === "running") next[phaseIdx] = {
|
|
2438
|
+
...next[phaseIdx],
|
|
2439
|
+
percent: Math.round(percent)
|
|
2440
|
+
};
|
|
2441
|
+
return next;
|
|
2442
|
+
});
|
|
2443
|
+
});
|
|
2444
|
+
const now = Date.now();
|
|
2445
|
+
setPhases((prev) => prev.map((p, i) => {
|
|
2446
|
+
if (p.status !== "done") return {
|
|
2447
|
+
...p,
|
|
2448
|
+
status: "done",
|
|
2449
|
+
percent: 100,
|
|
2450
|
+
durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
|
|
2451
|
+
};
|
|
2452
|
+
return p;
|
|
2453
|
+
}));
|
|
2454
|
+
setResult(res);
|
|
2455
|
+
setTimeout(() => exit(), 100);
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
const now = Date.now();
|
|
2458
|
+
setPhases((prev) => {
|
|
2459
|
+
const next = [...prev];
|
|
2460
|
+
for (let i = 0; i < next.length; i++) if (next[i].status === "running") next[i] = {
|
|
2461
|
+
...next[i],
|
|
2462
|
+
status: "failed",
|
|
2463
|
+
durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
|
|
2464
|
+
};
|
|
2465
|
+
return next;
|
|
2466
|
+
});
|
|
2467
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
2468
|
+
setError(failure.message);
|
|
2469
|
+
setTimeout(() => exit(failure), 100);
|
|
2470
|
+
}
|
|
2471
|
+
})();
|
|
2472
|
+
}, []);
|
|
2473
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
2474
|
+
flexDirection: "column",
|
|
2475
|
+
children: [
|
|
2476
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
2477
|
+
bold: true,
|
|
2478
|
+
children: [
|
|
2479
|
+
"▸ scene-sieve",
|
|
2480
|
+
" — ",
|
|
2481
|
+
props.input.split("/").pop()
|
|
2482
|
+
]
|
|
2483
|
+
}),
|
|
2484
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
2485
|
+
phases.map((phase, i) => /* @__PURE__ */ jsx(PhaseStep, { phase }, i)),
|
|
2486
|
+
error && /* @__PURE__ */ jsx(Box, {
|
|
2487
|
+
marginTop: 1,
|
|
2488
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
2489
|
+
color: "red",
|
|
2490
|
+
children: ["✗ Failed — ", error]
|
|
2491
|
+
})
|
|
2492
|
+
}),
|
|
2493
|
+
result && /* @__PURE__ */ jsxs(Box, {
|
|
2494
|
+
flexDirection: "column",
|
|
2495
|
+
marginTop: 1,
|
|
2496
|
+
children: [
|
|
2497
|
+
/* @__PURE__ */ jsx(Text, {
|
|
2498
|
+
color: "gray",
|
|
2499
|
+
children: " ────────────────────"
|
|
2500
|
+
}),
|
|
2501
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
2502
|
+
color: "green",
|
|
2503
|
+
bold: true,
|
|
2504
|
+
children: [
|
|
2505
|
+
"✓ Done",
|
|
2506
|
+
" — ",
|
|
2507
|
+
result.originalFramesCount,
|
|
2508
|
+
" frames →",
|
|
2509
|
+
" ",
|
|
2510
|
+
result.prunedFramesCount,
|
|
2511
|
+
" scenes (",
|
|
2512
|
+
(result.executionTimeMs / 1e3).toFixed(1),
|
|
2513
|
+
"s)"
|
|
2514
|
+
]
|
|
2515
|
+
}),
|
|
2516
|
+
result.animations && result.animations.length > 0 && /* @__PURE__ */ jsxs(Text, {
|
|
2517
|
+
color: "blue",
|
|
2518
|
+
children: [
|
|
2519
|
+
"ℹ Found",
|
|
2520
|
+
" ",
|
|
2521
|
+
result.animations.length,
|
|
2522
|
+
" animations (recorded in .metadata.json)"
|
|
2523
|
+
]
|
|
2524
|
+
}),
|
|
2525
|
+
props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs(Box, {
|
|
2526
|
+
flexDirection: "column",
|
|
2527
|
+
marginTop: 1,
|
|
2528
|
+
children: [/* @__PURE__ */ jsxs(Text, {
|
|
2529
|
+
color: "gray",
|
|
2530
|
+
children: ["Output: ", result.outputFiles[0]?.replace(/\/[^/]+$/, "/")]
|
|
2531
|
+
}), result.outputFiles.map((f, i) => /* @__PURE__ */ jsxs(Text, {
|
|
2532
|
+
color: "gray",
|
|
2533
|
+
children: [" - ", f]
|
|
2534
|
+
}, i))]
|
|
2535
|
+
})
|
|
2536
|
+
]
|
|
2537
|
+
})
|
|
2538
|
+
]
|
|
2539
|
+
});
|
|
2218
2540
|
};
|
|
2219
2541
|
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2542
|
+
//#endregion
|
|
2543
|
+
//#region src/cli.ts
|
|
2544
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
2545
|
+
const program = new Command();
|
|
2224
2546
|
program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version);
|
|
2225
2547
|
registerSieveCommand(program, version);
|
|
2226
2548
|
if (process.argv.includes("--describe")) {
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
},
|
|
2237
|
-
startTime,
|
|
2238
|
-
version
|
|
2239
|
-
);
|
|
2240
|
-
process.exit(0);
|
|
2549
|
+
const startTime = Date.now();
|
|
2550
|
+
respond("describe", {
|
|
2551
|
+
name: SIEVE_COMMAND.name,
|
|
2552
|
+
version,
|
|
2553
|
+
description: SIEVE_COMMAND.description,
|
|
2554
|
+
arguments: SIEVE_COMMAND.arguments,
|
|
2555
|
+
options: SIEVE_COMMAND.options
|
|
2556
|
+
}, startTime, version);
|
|
2557
|
+
process.exit(0);
|
|
2241
2558
|
}
|
|
2242
2559
|
program.parseAsync(process.argv).catch((error) => {
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
SieveErrorCode.UNKNOWN,
|
|
2247
|
-
error.message,
|
|
2248
|
-
Date.now(),
|
|
2249
|
-
version
|
|
2250
|
-
);
|
|
2251
|
-
} else {
|
|
2252
|
-
console.error("Fatal error:", error.message);
|
|
2253
|
-
}
|
|
2254
|
-
process.exit(1);
|
|
2560
|
+
if (process.argv.includes("--json")) respondError("extract", SieveErrorCode.UNKNOWN, error.message, Date.now(), version);
|
|
2561
|
+
else console.error("Fatal error:", error.message);
|
|
2562
|
+
process.exit(1);
|
|
2255
2563
|
});
|
|
2564
|
+
|
|
2565
|
+
//#endregion
|
|
2566
|
+
export { };
|