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