@lumy-pack/scene-sieve 0.0.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +47 -24
  2. package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
  3. package/dist/cli/index.d.ts +3 -0
  4. package/dist/{utils → cli/options}/parse-options.d.ts +6 -1
  5. package/dist/cli.mjs +2437 -2126
  6. package/dist/constants/pipeline-defaults.d.ts +13 -0
  7. package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
  8. package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
  9. package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
  10. package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
  11. package/dist/core/analyzer/features/frame-features.d.ts +32 -0
  12. package/dist/core/analyzer/index.d.ts +3 -0
  13. package/dist/core/constants/workspace-layout.d.ts +9 -0
  14. package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
  15. package/dist/core/extractor/index.d.ts +1 -0
  16. package/dist/core/index.d.ts +8 -9
  17. package/dist/core/input-resolver/index.d.ts +1 -0
  18. package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
  19. package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
  20. package/dist/core/orchestrator/index.d.ts +3 -0
  21. package/dist/core/{orchestrator.d.ts → orchestrator/orchestrator.d.ts} +1 -1
  22. package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
  23. package/dist/core/pruner/index.d.ts +1 -0
  24. package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
  25. package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
  26. package/dist/core/segmenter/index.d.ts +1 -0
  27. package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
  28. package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
  29. package/dist/core/workspace/index.d.ts +1 -0
  30. package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
  31. package/dist/index.cjs +1854 -1486
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.mjs +1836 -1455
  34. package/dist/pipeline-worker.mjs +1734 -1474
  35. package/dist/types/index.d.ts +25 -0
  36. package/package.json +5 -4
  37. package/dist/constants.d.ts +0 -32
  38. /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
  39. /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
  40. /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
  41. /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
  42. /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
  43. /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
  44. /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
  45. /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
  46. /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/index.cjs CHANGED
@@ -1,1606 +1,1974 @@
1
- "use strict";
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region \0rolldown/runtime.js
2
3
  var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
4
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
7
  var __getProtoOf = Object.getPrototypeOf;
7
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __export = (target, all) => {
9
- for (var name in all)
10
- __defProp(target, name, { get: all[name], enumerable: true });
9
+ var __esmMin = (fn, res, err) => () => {
10
+ if (err) throw err[0];
11
+ try {
12
+ return fn && (res = fn(fn = 0)), res;
13
+ } catch (e) {
14
+ throw err = [e], e;
15
+ }
11
16
  };
12
17
  var __copyProps = (to, from, except, desc) => {
13
- if (from && typeof from === "object" || typeof from === "function") {
14
- for (let key of __getOwnPropNames(from))
15
- if (!__hasOwnProp.call(to, key) && key !== except)
16
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
- }
18
- return to;
18
+ if (from && typeof from === "object" || typeof from === "function") {
19
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
20
+ key = keys[i];
21
+ if (!__hasOwnProp.call(to, key) && key !== except) {
22
+ __defProp(to, key, {
23
+ get: ((k) => from[k]).bind(null, key),
24
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
25
+ });
26
+ }
27
+ }
28
+ }
29
+ return to;
19
30
  };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
31
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
32
+ value: mod,
33
+ enumerable: true
34
+ }) : target, mod));
29
35
 
30
- // src/index.ts
31
- var src_exports = {};
32
- __export(src_exports, {
33
- extractScenes: () => runPipeline
34
- });
35
- module.exports = __toCommonJS(src_exports);
36
+ //#endregion
37
+ let node_crypto = require("node:crypto");
38
+ let _winglet_common_utils = require("@winglet/common-utils");
39
+ let picocolors = require("picocolors");
40
+ picocolors = __toESM(picocolors, 1);
41
+ let node_module = require("node:module");
42
+ let sharp = require("sharp");
43
+ sharp = __toESM(sharp, 1);
44
+ let node_fs_promises = require("node:fs/promises");
45
+ let node_path = require("node:path");
46
+ let _ffprobe_installer_ffprobe = require("@ffprobe-installer/ffprobe");
47
+ let execa = require("execa");
48
+ let ffmpeg_static = require("ffmpeg-static");
49
+ ffmpeg_static = __toESM(ffmpeg_static, 1);
50
+ let node_os = require("node:os");
36
51
 
37
- // ../../node_modules/tsup/assets/cjs_shims.js
38
- var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
39
- var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
-
41
- // src/core/orchestrator.ts
42
- var import_node_crypto2 = require("crypto");
43
- var import_common_utils7 = require("@winglet/common-utils");
44
-
45
- // src/utils/logger.ts
46
- var import_picocolors = __toESM(require("picocolors"), 1);
47
- var debugMode = false;
48
- var jsonMode = false;
52
+ //#region src/logging/logger.ts
49
53
  function setDebugMode(enabled) {
50
- debugMode = enabled;
54
+ debugMode = enabled;
51
55
  }
52
56
  function timestamp() {
53
- return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
57
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
54
58
  }
55
- var logger = {
56
- info(message) {
57
- if (jsonMode) {
58
- process.stderr.write(`${import_picocolors.default.blue("info")} ${message}
59
- `);
60
- } else {
61
- console.log(`${import_picocolors.default.blue("info")} ${message}`);
62
- }
63
- },
64
- success(message) {
65
- if (jsonMode) {
66
- process.stderr.write(`${import_picocolors.default.green("done")} ${message}
67
- `);
68
- } else {
69
- console.log(`
70
- ${import_picocolors.default.green("done")} ${message}`);
71
- }
72
- },
73
- warn(message) {
74
- console.warn(`${import_picocolors.default.yellow("warn")} ${message}`);
75
- },
76
- error(message) {
77
- console.error(`${import_picocolors.default.red("error")} ${message}`);
78
- },
79
- debug(message) {
80
- if (debugMode) {
81
- if (jsonMode) {
82
- process.stderr.write(`${import_picocolors.default.gray(`[${timestamp()}] debug`)} ${message}
83
- `);
84
- } else {
85
- console.log(`${import_picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
86
- }
87
- }
88
- }
89
- };
59
+ var debugMode, jsonMode, logger;
60
+ var init_logger = __esmMin((() => {
61
+ debugMode = false;
62
+ jsonMode = false;
63
+ logger = {
64
+ info(message) {
65
+ if (jsonMode) process.stderr.write(`${picocolors.default.blue("info")} ${message}\n`);
66
+ else console.log(`${picocolors.default.blue("info")} ${message}`);
67
+ },
68
+ success(message) {
69
+ if (jsonMode) process.stderr.write(`${picocolors.default.green("done")} ${message}\n`);
70
+ else console.log(`\n${picocolors.default.green("done")} ${message}`);
71
+ },
72
+ warn(message) {
73
+ console.warn(`${picocolors.default.yellow("warn")} ${message}`);
74
+ },
75
+ error(message) {
76
+ console.error(`${picocolors.default.red("error")} ${message}`);
77
+ },
78
+ debug(message) {
79
+ if (debugMode) if (jsonMode) process.stderr.write(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}\n`);
80
+ else console.log(`${picocolors.default.gray(`[${timestamp()}] debug`)} ${message}`);
81
+ }
82
+ };
83
+ }));
90
84
 
91
- // src/core/analyzer.ts
92
- var import_node_module = require("module");
93
- var import_common_utils = require("@winglet/common-utils");
94
- var import_sharp = __toESM(require("sharp"), 1);
85
+ //#endregion
86
+ //#region src/constants/pipeline-defaults.ts
87
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD;
88
+ var init_pipeline_defaults = __esmMin((() => {
89
+ DEFAULT_THRESHOLD = .5;
90
+ IOU_THRESHOLD = .9;
91
+ }));
95
92
 
96
- // src/constants.ts
97
- var import_node_os = require("os");
98
- var import_node_path = require("path");
99
- var APP_NAME = "scene-sieve";
100
- var DEFAULT_COUNT = 20;
101
- var DEFAULT_THRESHOLD = 0.5;
102
- var DEFAULT_FPS = 5;
103
- var DEFAULT_SCALE = 720;
104
- var DEFAULT_QUALITY = 80;
105
- var DEFAULT_MAX_FRAMES = 300;
106
- var NORMALIZATION_LOGISTIC_K = 3;
107
- var NORMALIZATION_ALPHA = 0.4;
108
- var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
109
- var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
110
- var WORKSPACE_PREFIX = `${APP_NAME}-`;
111
- var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
112
- var FRAME_OUTPUT_EXTENSION = ".jpg";
113
- var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
114
- var OPENCV_BATCH_SIZE = 10;
115
- var DBSCAN_ALPHA = 0.03;
116
- var DBSCAN_MIN_PTS = 4;
117
- var IOU_THRESHOLD = 0.9;
118
- var DECAY_LAMBDA = 0.95;
119
- var ANIMATION_FRAME_THRESHOLD = 5;
120
- var MATCH_DISTANCE_THRESHOLD = 0.25;
121
- var PIXELDIFF_GAUSSIAN_KERNEL = 3;
122
- var PIXELDIFF_BINARY_THRESHOLD = 30;
123
- var PIXELDIFF_CONTOUR_MIN_AREA = 100;
124
- var PIXELDIFF_SAMPLE_SPACING = 8;
125
- var DEFAULT_MAX_SEGMENT_DURATION = 300;
126
- var DEFAULT_SEGMENT_CONCURRENCY = 2;
127
- function getTempWorkspaceDir(sessionId) {
128
- return (0, import_node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
129
- }
93
+ //#endregion
94
+ //#region src/core/analyzer/constants/vision-tuning.ts
95
+ var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
96
+ var init_vision_tuning = __esmMin((() => {
97
+ DBSCAN_ALPHA = .03;
98
+ DECAY_LAMBDA = .95;
99
+ MATCH_DISTANCE_THRESHOLD = .25;
100
+ }));
130
101
 
131
- // src/core/dbscan.ts
132
- var UNVISITED = -2;
133
- var NOISE = -1;
102
+ //#endregion
103
+ //#region src/core/analyzer/clustering/dbscan.ts
104
+ /**
105
+ * DBSCAN clustering with resolution-independent eps.
106
+ * eps = alpha * sqrt(width^2 + height^2)
107
+ */
134
108
  function dbscan(points, imageWidth, imageHeight, alpha, minPts) {
135
- if (points.length === 0) {
136
- return { labels: [], boundingBoxes: [] };
137
- }
138
- const eps = (alpha ?? DBSCAN_ALPHA) * Math.sqrt(imageWidth ** 2 + imageHeight ** 2);
139
- const epsSquared = eps * eps;
140
- const minPoints = minPts ?? DBSCAN_MIN_PTS;
141
- const labels = new Array(points.length).fill(UNVISITED);
142
- let clusterId = 0;
143
- for (let i = 0; i < points.length; i++) {
144
- if (labels[i] !== UNVISITED) continue;
145
- const neighbors = findNeighbors(points, i, epsSquared);
146
- if (neighbors.length < minPoints) {
147
- labels[i] = NOISE;
148
- continue;
149
- }
150
- labels[i] = clusterId;
151
- const seeds = [...neighbors];
152
- const seedSet = new Set(seeds);
153
- for (let si = 0; si < seeds.length; si++) {
154
- const q = seeds[si];
155
- if (labels[q] === NOISE) {
156
- labels[q] = clusterId;
157
- }
158
- if (labels[q] !== UNVISITED) continue;
159
- labels[q] = clusterId;
160
- const qNeighbors = findNeighbors(points, q, epsSquared);
161
- if (qNeighbors.length >= minPoints) {
162
- for (const n of qNeighbors) {
163
- if (!seedSet.has(n)) {
164
- seedSet.add(n);
165
- seeds.push(n);
166
- }
167
- }
168
- }
169
- }
170
- clusterId++;
171
- }
172
- const boundingBoxes = [];
173
- for (let c = 0; c < clusterId; c++) {
174
- let minX = Infinity;
175
- let minY = Infinity;
176
- let maxX = -Infinity;
177
- let maxY = -Infinity;
178
- for (let i = 0; i < points.length; i++) {
179
- if (labels[i] !== c) continue;
180
- const p = points[i];
181
- if (p.x < minX) minX = p.x;
182
- if (p.y < minY) minY = p.y;
183
- if (p.x > maxX) maxX = p.x;
184
- if (p.y > maxY) maxY = p.y;
185
- }
186
- boundingBoxes.push({
187
- x: minX,
188
- y: minY,
189
- width: maxX - minX,
190
- height: maxY - minY
191
- });
192
- }
193
- return { labels, boundingBoxes };
109
+ if (points.length === 0) return {
110
+ labels: [],
111
+ boundingBoxes: []
112
+ };
113
+ const eps = (alpha ?? .03) * Math.sqrt(imageWidth ** 2 + imageHeight ** 2);
114
+ const epsSquared = eps * eps;
115
+ const minPoints = minPts ?? 4;
116
+ const labels = new Array(points.length).fill(UNVISITED);
117
+ let clusterId = 0;
118
+ for (let i = 0; i < points.length; i++) {
119
+ if (labels[i] !== UNVISITED) continue;
120
+ const neighbors = findNeighbors(points, i, epsSquared);
121
+ if (neighbors.length < minPoints) {
122
+ labels[i] = NOISE;
123
+ continue;
124
+ }
125
+ labels[i] = clusterId;
126
+ const seeds = [...neighbors];
127
+ const seedSet = new Set(seeds);
128
+ for (let si = 0; si < seeds.length; si++) {
129
+ const q = seeds[si];
130
+ if (labels[q] === NOISE) labels[q] = clusterId;
131
+ if (labels[q] !== UNVISITED) continue;
132
+ labels[q] = clusterId;
133
+ const qNeighbors = findNeighbors(points, q, epsSquared);
134
+ if (qNeighbors.length >= minPoints) {
135
+ for (const n of qNeighbors) if (!seedSet.has(n)) {
136
+ seedSet.add(n);
137
+ seeds.push(n);
138
+ }
139
+ }
140
+ }
141
+ clusterId++;
142
+ }
143
+ const boundingBoxes = [];
144
+ for (let c = 0; c < clusterId; c++) {
145
+ let minX = Infinity;
146
+ let minY = Infinity;
147
+ let maxX = -Infinity;
148
+ let maxY = -Infinity;
149
+ for (let i = 0; i < points.length; i++) {
150
+ if (labels[i] !== c) continue;
151
+ const p = points[i];
152
+ if (p.x < minX) minX = p.x;
153
+ if (p.y < minY) minY = p.y;
154
+ if (p.x > maxX) maxX = p.x;
155
+ if (p.y > maxY) maxY = p.y;
156
+ }
157
+ boundingBoxes.push({
158
+ x: minX,
159
+ y: minY,
160
+ width: maxX - minX,
161
+ height: maxY - minY
162
+ });
163
+ }
164
+ return {
165
+ labels,
166
+ boundingBoxes
167
+ };
194
168
  }
195
169
  function findNeighbors(points, idx, epsSquared) {
196
- const p = points[idx];
197
- const neighbors = [];
198
- for (let i = 0; i < points.length; i++) {
199
- if (i === idx) continue;
200
- const q = points[i];
201
- const distSq = (p.x - q.x) ** 2 + (p.y - q.y) ** 2;
202
- if (distSq <= epsSquared) {
203
- neighbors.push(i);
204
- }
205
- }
206
- return neighbors;
170
+ const p = points[idx];
171
+ const neighbors = [];
172
+ for (let i = 0; i < points.length; i++) {
173
+ if (i === idx) continue;
174
+ const q = points[i];
175
+ if ((p.x - q.x) ** 2 + (p.y - q.y) ** 2 <= epsSquared) neighbors.push(i);
176
+ }
177
+ return neighbors;
178
+ }
179
+ var UNVISITED, NOISE;
180
+ var init_dbscan = __esmMin((() => {
181
+ init_vision_tuning();
182
+ UNVISITED = -2;
183
+ NOISE = -1;
184
+ }));
185
+
186
+ //#endregion
187
+ //#region src/core/analyzer/features/feature-diff.ts
188
+ /**
189
+ * Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
190
+ * @param cvLib - Initialized OpenCV runtime.
191
+ * @param prev - Previous frame's live features, owned by the caller.
192
+ * @param next - Next frame's live features, owned by the caller.
193
+ * @returns Unmatched next-frame coordinates without changing input ownership.
194
+ * @throws Propagates matching errors after releasing temporary native handles.
195
+ */
196
+ function computeNewPoints(cvLib, prev, next) {
197
+ let matcher = null;
198
+ let matches = null;
199
+ try {
200
+ const matchedIndices = /* @__PURE__ */ new Set();
201
+ if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
202
+ matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
203
+ matches = new cvLib.DMatchVectorVector();
204
+ matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
205
+ for (let i = 0; i < matches.size(); i++) {
206
+ const pair = matches.get(i);
207
+ try {
208
+ if (pair.size() < 2) continue;
209
+ const best = pair.get(0);
210
+ const second = pair.get(1);
211
+ if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
212
+ } finally {
213
+ pair.delete();
214
+ }
215
+ }
216
+ } finally {
217
+ matcher?.delete();
218
+ }
219
+ const points = [];
220
+ for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
221
+ const { x, y } = next.keypoints.get(i).pt;
222
+ points.push({
223
+ x,
224
+ y
225
+ });
226
+ }
227
+ return points;
228
+ } finally {
229
+ matches?.delete();
230
+ }
231
+ }
232
+ var init_feature_diff = __esmMin((() => {
233
+ init_vision_tuning();
234
+ }));
235
+
236
+ //#endregion
237
+ //#region src/core/analyzer/features/frame-features.ts
238
+ /**
239
+ * Detect one frame's features without retaining its image or mask.
240
+ * @param cvLib - Initialized OpenCV runtime.
241
+ * @param akaze - Detector owned and released by the caller.
242
+ * @param frame - Grayscale bytes with matching width and height.
243
+ * @returns Feature handles that the caller must delete.
244
+ * @throws Propagates native errors after releasing partial allocations.
245
+ */
246
+ function computeFrameFeatures(cvLib, akaze, frame) {
247
+ let image = null;
248
+ let mask = null;
249
+ let keypoints = null;
250
+ let descriptors = null;
251
+ try {
252
+ image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
253
+ image.data.set(frame.data);
254
+ mask = new cvLib.Mat();
255
+ keypoints = new cvLib.KeyPointVector();
256
+ descriptors = new cvLib.Mat();
257
+ akaze.detectAndCompute(image, mask, keypoints, descriptors);
258
+ const ownedKeypoints = keypoints;
259
+ const ownedDescriptors = descriptors;
260
+ let deleted = false;
261
+ const features = {
262
+ width: frame.width,
263
+ height: frame.height,
264
+ keypoints: ownedKeypoints,
265
+ descriptors: ownedDescriptors,
266
+ /** Release the transferred handles exactly once. */
267
+ delete() {
268
+ if (deleted) return;
269
+ deleted = true;
270
+ try {
271
+ ownedKeypoints.delete();
272
+ } finally {
273
+ ownedDescriptors.delete();
274
+ }
275
+ }
276
+ };
277
+ keypoints = null;
278
+ descriptors = null;
279
+ return features;
280
+ } finally {
281
+ image?.delete();
282
+ mask?.delete();
283
+ keypoints?.delete();
284
+ descriptors?.delete();
285
+ }
207
286
  }
287
+ var init_frame_features = __esmMin((() => {}));
208
288
 
209
- // src/core/analyzer.ts
210
- var OPENCV_INIT_TIMEOUT_MS = 3e4;
211
- var require2 = (0, import_node_module.createRequire)(importMetaUrl);
212
- var cvReady = null;
289
+ //#endregion
290
+ //#region src/core/analyzer/analyzer.ts
213
291
  async function ensureOpenCV() {
214
- if (!cvReady) {
215
- cvReady = (async () => {
216
- const cvObj = require2("@techstark/opencv-js");
217
- delete cvObj.then;
218
- if (cvObj.Mat) return cvObj;
219
- return new Promise((resolve2, reject) => {
220
- const timeout = setTimeout(() => {
221
- reject(new Error("OpenCV WASM initialization timed out after 30s"));
222
- }, OPENCV_INIT_TIMEOUT_MS);
223
- cvObj.onRuntimeInitialized = () => {
224
- clearTimeout(timeout);
225
- resolve2(cvObj);
226
- };
227
- });
228
- })();
229
- }
230
- return cvReady;
292
+ if (!cvReady) cvReady = (async () => {
293
+ const cvObj = require$1("@techstark/opencv-js");
294
+ delete cvObj.then;
295
+ if (cvObj.Mat) return cvObj;
296
+ return new Promise((resolve, reject) => {
297
+ const timeout = setTimeout(() => {
298
+ reject(/* @__PURE__ */ new Error("OpenCV WASM initialization timed out after 30s"));
299
+ }, OPENCV_INIT_TIMEOUT_MS);
300
+ cvObj.onRuntimeInitialized = () => {
301
+ clearTimeout(timeout);
302
+ resolve(cvObj);
303
+ };
304
+ });
305
+ })();
306
+ return cvReady;
231
307
  }
232
308
  async function preprocessFrame(framePath, scale) {
233
- const { data, info } = await (0, import_sharp.default)(framePath).resize({ width: scale, withoutEnlargement: true }).grayscale().blur(1).raw().toBuffer({ resolveWithObject: true });
234
- return {
235
- data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
236
- width: info.width,
237
- height: info.height
238
- };
309
+ const { data, info } = await (0, sharp.default)(framePath).resize({
310
+ width: scale,
311
+ withoutEnlargement: true
312
+ }).grayscale().blur(1).raw().toBuffer({ resolveWithObject: true });
313
+ return {
314
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
315
+ width: info.width,
316
+ height: info.height
317
+ };
239
318
  }
240
319
  function computeIoU(a, b) {
241
- const ix1 = Math.max(a.x, b.x);
242
- const iy1 = Math.max(a.y, b.y);
243
- const ix2 = Math.min(a.x + a.width, b.x + b.width);
244
- const iy2 = Math.min(a.y + a.height, b.y + b.height);
245
- const iw = Math.max(0, ix2 - ix1);
246
- const ih = Math.max(0, iy2 - iy1);
247
- const intersection = iw * ih;
248
- if (intersection === 0) return 0;
249
- const aArea = a.width * a.height;
250
- const bArea = b.width * b.height;
251
- const union = aArea + bArea - intersection;
252
- return union === 0 ? 0 : intersection / union;
253
- }
254
- var IoUTracker = class {
255
- constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
256
- this.fps = fps;
257
- this.iouThreshold = iouThreshold;
258
- this.animationThreshold = animationThreshold;
259
- }
260
- regions = [];
261
- extractedAnimations = [];
262
- update(boxes, pairIndex) {
263
- const animationIndices = /* @__PURE__ */ new Set();
264
- const matched = /* @__PURE__ */ new Set();
265
- for (let bi = 0; bi < boxes.length; bi++) {
266
- const box = boxes[bi];
267
- let bestIoU = 0;
268
- let bestRegionIdx = -1;
269
- for (let ri = 0; ri < this.regions.length; ri++) {
270
- if (matched.has(ri)) continue;
271
- const iou = computeIoU(box, this.regions[ri].box);
272
- if (iou > bestIoU) {
273
- bestIoU = iou;
274
- bestRegionIdx = ri;
275
- }
276
- }
277
- if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
278
- const region = this.regions[bestRegionIdx];
279
- const gap = pairIndex - region.lastSeen;
280
- region.box = box;
281
- region.consecutiveCount++;
282
- region.lastSeen = pairIndex;
283
- region.weight *= Math.pow(DECAY_LAMBDA, gap);
284
- matched.add(bestRegionIdx);
285
- if (region.consecutiveCount >= this.animationThreshold) {
286
- animationIndices.add(bi);
287
- }
288
- } else {
289
- this.regions.push({
290
- box,
291
- consecutiveCount: 1,
292
- firstSeen: pairIndex,
293
- lastSeen: pairIndex,
294
- weight: 1
295
- });
296
- }
297
- }
298
- for (let ri = 0; ri < this.regions.length; ri++) {
299
- if (!matched.has(ri)) {
300
- const gap = pairIndex - this.regions[ri].lastSeen;
301
- this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
302
- }
303
- }
304
- for (let i = 0; i < this.regions.length; i++) {
305
- const region = this.regions[i];
306
- if (region.weight <= 0.01 && !matched.has(i)) {
307
- this.collectAnimation(region);
308
- }
309
- }
310
- this.regions = (0, import_common_utils.filter)(
311
- this.regions,
312
- (r, i) => r.weight > 0.01 || matched.has(i)
313
- );
314
- return animationIndices;
315
- }
316
- collectAnimation(region) {
317
- if (region.consecutiveCount >= this.animationThreshold) {
318
- const durationMs = region.consecutiveCount / this.fps * 1e3;
319
- this.extractedAnimations.push({
320
- type: "loading_spinner",
321
- // 기본값으로 loading_spinner 사용
322
- boundingBox: region.box,
323
- startFrameId: region.firstSeen,
324
- endFrameId: region.lastSeen,
325
- durationMs
326
- });
327
- }
328
- }
329
- flushAndGetAnimations() {
330
- for (const region of this.regions) {
331
- this.collectAnimation(region);
332
- }
333
- this.regions = [];
334
- return this.extractedAnimations;
335
- }
336
- getAnimationWeight(boxIndex, boxes) {
337
- if (boxIndex >= boxes.length) return 0;
338
- const box = boxes[boxIndex];
339
- let maxWeight = 0;
340
- for (const region of this.regions) {
341
- if (region.consecutiveCount >= this.animationThreshold) {
342
- const iou = computeIoU(box, region.box);
343
- if (iou > this.iouThreshold) {
344
- maxWeight = Math.max(maxWeight, region.weight);
345
- }
346
- }
347
- }
348
- return maxWeight;
349
- }
350
- };
351
- async function computeAKAZEDiff(cvLib, frame1, frame2) {
352
- const cv = cvLib;
353
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
354
- mat1.data.set(frame1.data);
355
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
356
- mat2.data.set(frame2.data);
357
- const kp1 = new cvLib.KeyPointVector();
358
- const kp2 = new cvLib.KeyPointVector();
359
- const desc1 = new cvLib.Mat();
360
- const desc2 = new cvLib.Mat();
361
- const mask1 = new cvLib.Mat();
362
- const mask2 = new cvLib.Mat();
363
- const akaze = new cvLib.AKAZE();
364
- let matches = null;
365
- try {
366
- akaze.detectAndCompute(mat1, mask1, kp1, desc1);
367
- akaze.detectAndCompute(mat2, mask2, kp2, desc2);
368
- const matchedKp1Indices = /* @__PURE__ */ new Set();
369
- const matchedKp2Indices = /* @__PURE__ */ new Set();
370
- if (desc1.rows > 0 && desc2.rows > 0) {
371
- const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
372
- try {
373
- matches = new cvLib.DMatchVectorVector();
374
- matcher.knnMatch(desc1, desc2, matches, 2);
375
- for (let i = 0; i < matches.size(); i++) {
376
- const pair = matches.get(i);
377
- if (pair.size() < 2) continue;
378
- const m0 = pair.get(0);
379
- const m1 = pair.get(1);
380
- if (m0.distance < MATCH_DISTANCE_THRESHOLD * m1.distance) {
381
- matchedKp1Indices.add(m0.queryIdx);
382
- matchedKp2Indices.add(m0.trainIdx);
383
- }
384
- }
385
- } finally {
386
- matcher.delete();
387
- }
388
- }
389
- const sNew = [];
390
- for (let i = 0; i < kp2.size(); i++) {
391
- if (!matchedKp2Indices.has(i)) {
392
- const pt = kp2.get(i).pt;
393
- sNew.push({ x: pt.x, y: pt.y });
394
- }
395
- }
396
- const sLoss = [];
397
- for (let i = 0; i < kp1.size(); i++) {
398
- if (!matchedKp1Indices.has(i)) {
399
- const pt = kp1.get(i).pt;
400
- sLoss.push({ x: pt.x, y: pt.y });
401
- }
402
- }
403
- return { sNew, sLoss };
404
- } finally {
405
- mat1.delete();
406
- mat2.delete();
407
- kp1.delete();
408
- kp2.delete();
409
- desc1.delete();
410
- desc2.delete();
411
- mask1.delete();
412
- mask2.delete();
413
- akaze.delete();
414
- if (matches) matches.delete();
415
- }
320
+ const ix1 = Math.max(a.x, b.x);
321
+ const iy1 = Math.max(a.y, b.y);
322
+ const ix2 = Math.min(a.x + a.width, b.x + b.width);
323
+ const iy2 = Math.min(a.y + a.height, b.y + b.height);
324
+ const intersection = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
325
+ if (intersection === 0) return 0;
326
+ const union = a.width * a.height + b.width * b.height - intersection;
327
+ return union === 0 ? 0 : intersection / union;
416
328
  }
329
+ /**
330
+ * Pixel-level difference fallback for AKAZE blind spots.
331
+ *
332
+ * When AKAZE produces sparse results (typical for UI screen recordings
333
+ * where form fields, dropdowns, or overlays change), this function
334
+ * detects changed regions via cv.absdiff and generates synthetic
335
+ * Point2D[] that feed into the existing DBSCAN → IoU → G(t) pipeline.
336
+ *
337
+ * Algorithm:
338
+ * 1. absdiff(frame1, frame2) → grayscale difference
339
+ * 2. GaussianBlur → reduce JPEG compression noise
340
+ * 3. threshold → binary mask of significant changes
341
+ * 4. findContours → bounding rects of changed regions
342
+ * 5. Grid sampling within each bounding rect → Point2D[]
343
+ *
344
+ * @param cvLib - Initialized OpenCV runtime shared by the analyzer.
345
+ * @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
346
+ * @param frame2 - Next grayscale frame, with the same dimensions as frame1.
347
+ * @returns Grid-sampled points from changed regions.
348
+ * @throws Propagates allocation or OpenCV errors after releasing acquired handles.
349
+ */
417
350
  function computePixelDiff(cvLib, frame1, frame2) {
418
- const cv = cvLib;
419
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
420
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
421
- const diff = new cv.Mat();
422
- const blurred = new cv.Mat();
423
- const binary = new cv.Mat();
424
- const contours = new cv.MatVector();
425
- const hierarchy = new cv.Mat();
426
- try {
427
- mat1.data.set(frame1.data);
428
- mat2.data.set(frame2.data);
429
- cv.absdiff(mat1, mat2, diff);
430
- const ksize = new cv.Size(
431
- PIXELDIFF_GAUSSIAN_KERNEL,
432
- PIXELDIFF_GAUSSIAN_KERNEL
433
- );
434
- cv.GaussianBlur(diff, blurred, ksize, 0);
435
- cv.threshold(
436
- blurred,
437
- binary,
438
- PIXELDIFF_BINARY_THRESHOLD,
439
- 255,
440
- cv.THRESH_BINARY
441
- );
442
- cv.findContours(
443
- binary,
444
- contours,
445
- hierarchy,
446
- cv.RETR_EXTERNAL,
447
- cv.CHAIN_APPROX_SIMPLE
448
- );
449
- const points = [];
450
- for (let c = 0; c < contours.size(); c++) {
451
- const contour = contours.get(c);
452
- const rect = cv.boundingRect(contour);
453
- if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
454
- for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
455
- for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
456
- points.push({ x, y });
457
- }
458
- }
459
- }
460
- return points;
461
- } finally {
462
- mat1.delete();
463
- mat2.delete();
464
- diff.delete();
465
- blurred.delete();
466
- binary.delete();
467
- contours.delete();
468
- hierarchy.delete();
469
- }
351
+ const cv = cvLib;
352
+ let mat1 = null;
353
+ let mat2 = null;
354
+ let diff = null;
355
+ let blurred = null;
356
+ let binary = null;
357
+ let contours = null;
358
+ let hierarchy = null;
359
+ try {
360
+ mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
361
+ mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
362
+ diff = new cv.Mat();
363
+ blurred = new cv.Mat();
364
+ binary = new cv.Mat();
365
+ contours = new cv.MatVector();
366
+ hierarchy = new cv.Mat();
367
+ mat1.data.set(frame1.data);
368
+ mat2.data.set(frame2.data);
369
+ cv.absdiff(mat1, mat2, diff);
370
+ const ksize = new cv.Size(3, 3);
371
+ cv.GaussianBlur(diff, blurred, ksize, 0);
372
+ cv.threshold(blurred, binary, 30, 255, cv.THRESH_BINARY);
373
+ cv.findContours(binary, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE);
374
+ const points = [];
375
+ for (let c = 0; c < contours.size(); c++) {
376
+ const contour = contours.get(c);
377
+ try {
378
+ const rect = cv.boundingRect(contour);
379
+ if (rect.width * rect.height < 100) continue;
380
+ 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({
381
+ x,
382
+ y
383
+ });
384
+ } finally {
385
+ contour.delete();
386
+ }
387
+ }
388
+ return points;
389
+ } finally {
390
+ mat1?.delete();
391
+ mat2?.delete();
392
+ diff?.delete();
393
+ blurred?.delete();
394
+ binary?.delete();
395
+ contours?.delete();
396
+ hierarchy?.delete();
397
+ }
470
398
  }
471
399
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
472
- if (clusters.length === 0) return 0;
473
- let gain = 0;
474
- for (let i = 0; i < clusters.length; i++) {
475
- const box = clusters[i];
476
- const clusterArea = box.width * box.height;
477
- if (clusterArea <= 0) continue;
478
- const normalizedArea = clusterArea / imageArea;
479
- const featureDensity = clusterPoints[i] / clusterArea;
480
- let contribution = normalizedArea * featureDensity;
481
- if (animationIndices.has(i)) {
482
- const animWeight = animationWeights[i] ?? 0;
483
- contribution *= 1 - animWeight;
484
- }
485
- gain += contribution;
486
- }
487
- return gain;
400
+ if (clusters.length === 0) return 0;
401
+ let gain = 0;
402
+ for (let i = 0; i < clusters.length; i++) {
403
+ const box = clusters[i];
404
+ const clusterArea = box.width * box.height;
405
+ if (clusterArea <= 0) continue;
406
+ let contribution = clusterArea / imageArea * (clusterPoints[i] / clusterArea);
407
+ if (animationIndices.has(i)) {
408
+ const animWeight = animationWeights[i] ?? 0;
409
+ contribution *= 1 - animWeight;
410
+ }
411
+ gain += contribution;
412
+ }
413
+ return gain;
488
414
  }
489
- async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
490
- const edges = [];
491
- const preprocessed = await Promise.all(
492
- (0, import_common_utils.map)(frames, (f) => preprocessFrame(f.extractPath, scale))
493
- );
494
- const imageWidth = preprocessed[0]?.width ?? scale;
495
- const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
496
- const imageArea = imageWidth * imageHeight;
497
- for (let i = 0; i < frames.length - 1; i++) {
498
- const pairIndex = pairOffset + i;
499
- try {
500
- const { sNew } = await computeAKAZEDiff(
501
- cvLib,
502
- preprocessed[i],
503
- preprocessed[i + 1]
504
- );
505
- let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
506
- let clusters = dbscanResult.boundingBoxes;
507
- if (clusters.length === 0) {
508
- const pixelDiffPoints = computePixelDiff(
509
- cvLib,
510
- preprocessed[i],
511
- preprocessed[i + 1]
512
- );
513
- if (pixelDiffPoints.length > 0) {
514
- logger.debug(
515
- `Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
516
- );
517
- dbscanResult = dbscan(
518
- pixelDiffPoints,
519
- imageWidth,
520
- imageHeight,
521
- void 0,
522
- 2
523
- );
524
- clusters = dbscanResult.boundingBoxes;
525
- }
526
- }
527
- const clusterPointCounts = new Array(clusters.length).fill(0);
528
- for (const label of dbscanResult.labels) {
529
- if (label >= 0) {
530
- clusterPointCounts[label]++;
531
- }
532
- }
533
- const animationIndices = tracker.update(clusters, pairIndex);
534
- const animationWeights = (0, import_common_utils.map)(
535
- clusters,
536
- (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0
537
- );
538
- const score = computeInformationGain(
539
- clusters,
540
- clusterPointCounts,
541
- imageArea,
542
- animationIndices,
543
- animationWeights
544
- );
545
- logger.debug(
546
- `Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
547
- );
548
- edges.push({
549
- sourceId: frames[i].id,
550
- targetId: frames[i + 1].id,
551
- score
552
- });
553
- } catch (err) {
554
- logger.debug(`Frame pair analysis failed: ${String(err)}`);
555
- edges.push({
556
- sourceId: frames[i].id,
557
- targetId: frames[i + 1].id,
558
- score: 0
559
- });
560
- }
561
- }
562
- return edges;
415
+ /**
416
+ * Analyze one batch, retaining its boundary frame for the following batch.
417
+ * @param cvLib - Initialized OpenCV runtime.
418
+ * @param akaze - Detector owned by analyzeFrames.
419
+ * @param frames - Boundary frame followed by new adjacent frames.
420
+ * @param carry - Boundary bytes and live features transferred from the previous batch.
421
+ * @param scale - Maximum preprocessing width.
422
+ * @param tracker - Stateful animation tracker shared across batches.
423
+ * @param pairOffset - Global position of this batch's first pair.
424
+ * @returns Scores, pair failure count, and ownership of the final frame's features.
425
+ */
426
+ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
427
+ const edges = [];
428
+ let failures = 0;
429
+ let prev = carry?.features ?? null;
430
+ let next = null;
431
+ try {
432
+ const preprocessed = await Promise.all((0, _winglet_common_utils.map)(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
433
+ const imageWidth = preprocessed[0]?.width ?? scale;
434
+ const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
435
+ const imageArea = imageWidth * imageHeight;
436
+ for (let i = 0; i < frames.length - 1; i++) {
437
+ const pairIndex = pairOffset + i;
438
+ try {
439
+ prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
440
+ next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
441
+ let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
442
+ let clusters = dbscanResult.boundingBoxes;
443
+ if (clusters.length === 0) {
444
+ const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
445
+ if (pixelDiffPoints.length > 0) {
446
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
447
+ dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
448
+ clusters = dbscanResult.boundingBoxes;
449
+ }
450
+ }
451
+ const clusterPointCounts = new Array(clusters.length).fill(0);
452
+ for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
453
+ const animationIndices = tracker.update(clusters, pairIndex);
454
+ const animationWeights = (0, _winglet_common_utils.map)(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
455
+ const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
456
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
457
+ edges.push({
458
+ sourceId: frames[i].id,
459
+ targetId: frames[i + 1].id,
460
+ score
461
+ });
462
+ } catch (err) {
463
+ logger.warn(`Frame pair analysis failed: ${String(err)}`);
464
+ failures++;
465
+ edges.push({
466
+ sourceId: frames[i].id,
467
+ targetId: frames[i + 1].id,
468
+ score: 0
469
+ });
470
+ } finally {
471
+ prev?.delete();
472
+ prev = next;
473
+ next = null;
474
+ }
475
+ }
476
+ const result = {
477
+ edges,
478
+ failures,
479
+ analysisResolution: {
480
+ width: imageWidth,
481
+ height: imageHeight
482
+ },
483
+ carry: prev ? {
484
+ preprocessed: preprocessed[preprocessed.length - 1],
485
+ features: prev
486
+ } : null
487
+ };
488
+ prev = null;
489
+ return result;
490
+ } finally {
491
+ prev?.delete();
492
+ next?.delete();
493
+ }
563
494
  }
495
+ /**
496
+ * Analyze adjacent frame pairs to compute information gain scores (G(t)).
497
+ * Processes frames in batches for memory efficiency.
498
+ *
499
+ * Pipeline:
500
+ * 1. AKAZE Feature Set Difference
501
+ * 2. DBSCAN Spatial Clustering
502
+ * 3. Spatio-temporal IoU Tracking
503
+ * 4. G(t) Information Gain Scoring
504
+ * @param ctx - Frames, analysis options, and the progress callback for this run.
505
+ * @returns Adjacent scores and tracked animations in analysis coordinates.
506
+ * @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
507
+ */
564
508
  async function analyzeFrames(ctx) {
565
- const { frames } = ctx;
566
- if (frames.length < 2) return { edges: [], animations: [] };
567
- logger.debug(
568
- `Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
569
- );
570
- const cvLib = await ensureOpenCV();
571
- const edges = [];
572
- const tracker = new IoUTracker(
573
- ctx.options.fps,
574
- ctx.options.iouThreshold,
575
- ctx.options.animationThreshold
576
- );
577
- const scale = ctx.options.scale;
578
- for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
579
- const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
580
- const batch = frames.slice(i, batchEnd);
581
- const batchEdges = await analyzeBatch(cvLib, batch, scale, tracker, i);
582
- edges.push(...batchEdges);
583
- const progress = Math.min(
584
- 100,
585
- (i + OPENCV_BATCH_SIZE) / (frames.length - 1) * 100
586
- );
587
- ctx.emitProgress(progress);
588
- }
589
- const animations = tracker.flushAndGetAnimations();
590
- logger.debug(
591
- `Computed ${edges.length} score edges and ${animations.length} animations`
592
- );
593
- return { edges, animations };
509
+ const { frames } = ctx;
510
+ if (frames.length < 2) return {
511
+ edges: [],
512
+ animations: [],
513
+ analysisResolution: {
514
+ width: 0,
515
+ height: 0
516
+ }
517
+ };
518
+ logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
519
+ const cvLib = await ensureOpenCV();
520
+ const edges = [];
521
+ const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
522
+ const scale = ctx.options.scale;
523
+ let akaze = null;
524
+ let carry = null;
525
+ let analysisResolution = {
526
+ width: 0,
527
+ height: 0
528
+ };
529
+ let failures = 0;
530
+ try {
531
+ akaze = new cvLib.AKAZE();
532
+ for (let i = 0; i < frames.length - 1; i += 10) {
533
+ const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
534
+ const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
535
+ carry = result.carry;
536
+ failures += result.failures;
537
+ if (i === 0) analysisResolution = result.analysisResolution;
538
+ edges.push(...result.edges);
539
+ const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
540
+ ctx.emitProgress(progress);
541
+ }
542
+ } finally {
543
+ carry?.features.delete();
544
+ akaze?.delete();
545
+ }
546
+ const pairs = frames.length - 1;
547
+ if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
548
+ const animations = tracker.flushAndGetAnimations();
549
+ logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
550
+ return {
551
+ edges,
552
+ animations,
553
+ analysisResolution
554
+ };
555
+ }
556
+ var OPENCV_INIT_TIMEOUT_MS, require$1, cvReady, IoUTracker;
557
+ var init_analyzer$1 = __esmMin((() => {
558
+ init_pipeline_defaults();
559
+ init_vision_tuning();
560
+ init_logger();
561
+ init_dbscan();
562
+ init_feature_diff();
563
+ init_frame_features();
564
+ OPENCV_INIT_TIMEOUT_MS = 3e4;
565
+ require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
566
+ cvReady = null;
567
+ IoUTracker = class {
568
+ fps;
569
+ iouThreshold;
570
+ animationThreshold;
571
+ regions = [];
572
+ extractedAnimations = [];
573
+ constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
574
+ this.fps = fps;
575
+ this.iouThreshold = iouThreshold;
576
+ this.animationThreshold = animationThreshold;
577
+ }
578
+ update(boxes, pairIndex) {
579
+ const animationIndices = /* @__PURE__ */ new Set();
580
+ const matched = /* @__PURE__ */ new Set();
581
+ for (let bi = 0; bi < boxes.length; bi++) {
582
+ const box = boxes[bi];
583
+ let bestIoU = 0;
584
+ let bestRegionIdx = -1;
585
+ for (let ri = 0; ri < this.regions.length; ri++) {
586
+ if (matched.has(ri)) continue;
587
+ const iou = computeIoU(box, this.regions[ri].box);
588
+ if (iou > bestIoU) {
589
+ bestIoU = iou;
590
+ bestRegionIdx = ri;
591
+ }
592
+ }
593
+ if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
594
+ const region = this.regions[bestRegionIdx];
595
+ const gap = pairIndex - region.lastSeen;
596
+ region.box = box;
597
+ region.consecutiveCount++;
598
+ region.lastSeen = pairIndex;
599
+ region.weight *= Math.pow(DECAY_LAMBDA, gap);
600
+ matched.add(bestRegionIdx);
601
+ if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
602
+ } else this.regions.push({
603
+ box,
604
+ consecutiveCount: 1,
605
+ firstSeen: pairIndex,
606
+ lastSeen: pairIndex,
607
+ weight: 1
608
+ });
609
+ }
610
+ for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
611
+ const gap = pairIndex - this.regions[ri].lastSeen;
612
+ this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
613
+ }
614
+ for (let i = 0; i < this.regions.length; i++) {
615
+ const region = this.regions[i];
616
+ if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
617
+ }
618
+ this.regions = (0, _winglet_common_utils.filter)(this.regions, (r, i) => r.weight > .01 || matched.has(i));
619
+ return animationIndices;
620
+ }
621
+ collectAnimation(region) {
622
+ if (region.consecutiveCount >= this.animationThreshold) {
623
+ const durationMs = region.consecutiveCount / this.fps * 1e3;
624
+ this.extractedAnimations.push({
625
+ type: "loading_spinner",
626
+ boundingBox: region.box,
627
+ startFrameId: region.firstSeen,
628
+ endFrameId: region.lastSeen,
629
+ durationMs
630
+ });
631
+ }
632
+ }
633
+ flushAndGetAnimations() {
634
+ for (const region of this.regions) this.collectAnimation(region);
635
+ this.regions = [];
636
+ return this.extractedAnimations;
637
+ }
638
+ getAnimationWeight(boxIndex, boxes) {
639
+ if (boxIndex >= boxes.length) return 0;
640
+ const box = boxes[boxIndex];
641
+ let maxWeight = 0;
642
+ for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
643
+ if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
644
+ }
645
+ return maxWeight;
646
+ }
647
+ };
648
+ }));
649
+
650
+ //#endregion
651
+ //#region src/core/analyzer/index.ts
652
+ var init_analyzer = __esmMin((() => {
653
+ init_analyzer$1();
654
+ init_dbscan();
655
+ }));
656
+
657
+ //#endregion
658
+ //#region src/core/utils/metadata/build-video-metadata.ts
659
+ /**
660
+ * Read output dimensions and build consistent video and animation metadata.
661
+ * JPEG finalization does not resize, so the source dimensions match the output.
662
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
663
+ * @param selected Selected frames in output order; the first candidate is the fallback.
664
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
665
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
666
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
667
+ */
668
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
669
+ const firstFrame = selected[0] ?? ctx.frames[0];
670
+ const dimensions = firstFrame ? await (0, sharp.default)(firstFrame.extractPath).metadata() : void 0;
671
+ const width = dimensions?.width ?? 0;
672
+ const height = dimensions?.height ?? 0;
673
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
674
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
675
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
676
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
677
+ return {
678
+ video: {
679
+ originalDurationMs: Math.round(duration * 1e3),
680
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
681
+ resolution: {
682
+ width,
683
+ height
684
+ }
685
+ },
686
+ animations: (ctx.animations ?? []).map((animation) => {
687
+ const box = animation.boundingBox;
688
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
689
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
690
+ return {
691
+ ...animation,
692
+ boundingBox: {
693
+ x,
694
+ y,
695
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
696
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
697
+ }
698
+ };
699
+ })
700
+ };
594
701
  }
702
+ var init_build_video_metadata = __esmMin((() => {}));
595
703
 
596
- // src/core/extractor.ts
597
- var import_promises2 = require("fs/promises");
598
- var import_node_path3 = require("path");
599
- var import_ffprobe = require("@ffprobe-installer/ffprobe");
600
- var import_common_utils2 = require("@winglet/common-utils");
601
- var import_execa = require("execa");
602
- var import_ffmpeg_static = __toESM(require("ffmpeg-static"), 1);
704
+ //#endregion
705
+ //#region src/core/constants/workspace-layout.ts
706
+ function getTempWorkspaceDir(sessionId) {
707
+ return (0, node_path.join)(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
708
+ }
709
+ var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
710
+ var init_workspace_layout = __esmMin((() => {
711
+ APP_NAME = "scene-sieve";
712
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
713
+ TEMP_BASE_DIR = (0, node_os.tmpdir)();
714
+ FRAME_OUTPUT_EXTENSION = ".jpg";
715
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
716
+ }));
603
717
 
604
- // src/utils/paths.ts
605
- var import_promises = require("fs/promises");
606
- var import_node_os2 = require("os");
607
- var import_node_path2 = require("path");
718
+ //#endregion
719
+ //#region src/core/utils/filesystem/paths.ts
608
720
  async function ensureDir(dirPath) {
609
- await (0, import_promises.mkdir)(dirPath, { recursive: true });
721
+ await (0, node_fs_promises.mkdir)(dirPath, { recursive: true });
610
722
  }
611
723
  async function fileExists(filePath) {
612
- try {
613
- await (0, import_promises.stat)(filePath);
614
- return true;
615
- } catch {
616
- return false;
617
- }
724
+ try {
725
+ await (0, node_fs_promises.stat)(filePath);
726
+ return true;
727
+ } catch {
728
+ return false;
729
+ }
618
730
  }
731
+ /**
732
+ * Expand leading ~ to homedir. Node's path.resolve() does not expand ~,
733
+ * so paths like ~/Desktop/foo depend on process.cwd() and can produce
734
+ * different results when run from different directories.
735
+ */
619
736
  function expandTilde(p) {
620
- if (p === "~") return (0, import_node_os2.homedir)();
621
- if (p.startsWith("~/") || p.startsWith("~\\")) {
622
- return (0, import_node_path2.resolve)((0, import_node_os2.homedir)(), p.slice(2));
623
- }
624
- return p;
737
+ if (p === "~") return (0, node_os.homedir)();
738
+ if (p.startsWith("~/") || p.startsWith("~\\")) return (0, node_path.resolve)((0, node_os.homedir)(), p.slice(2));
739
+ return p;
625
740
  }
741
+ /**
742
+ * Resolve path to absolute. Expands ~ to homedir first so that the result
743
+ * does not depend on process.cwd().
744
+ */
626
745
  function resolveAbsolute(p) {
627
- return (0, import_node_path2.resolve)(expandTilde(p));
746
+ return (0, node_path.resolve)(expandTilde(p));
628
747
  }
748
+ /**
749
+ * Derive default output directory name from input file path.
750
+ * e.g., /path/to/video.mp4 -> /path/to/video_scenes
751
+ */
629
752
  function deriveOutputPath(inputPath) {
630
- const dir = (0, import_node_path2.resolve)(inputPath, "..");
631
- const name = (0, import_node_path2.basename)(inputPath, (0, import_node_path2.extname)(inputPath));
632
- return (0, import_node_path2.resolve)(dir, `${name}_scenes`);
753
+ return (0, node_path.resolve)((0, node_path.resolve)(inputPath, ".."), `${(0, node_path.basename)(inputPath, (0, node_path.extname)(inputPath))}_scenes`);
633
754
  }
755
+ var init_paths = __esmMin((() => {}));
634
756
 
635
- // src/core/extractor.ts
757
+ //#endregion
758
+ //#region src/core/extractor/extractor.ts
759
+ /**
760
+ * Extract frames from video/GIF using FFmpeg.
761
+ * @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
762
+ * @returns Extracted candidates, or the unchanged input array in frames mode.
763
+ * @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
764
+ */
636
765
  async function extractFrames(ctx) {
637
- const framesDir = (0, import_node_path3.join)(ctx.workspacePath, "frames");
638
- const { inputPath, fps, maxFrames, scale } = ctx.options;
639
- if (!inputPath) {
640
- throw new Error("inputPath is required for frame extraction");
641
- }
642
- const exists = await fileExists(inputPath);
643
- if (!exists) {
644
- throw new Error(`Input file not found: ${inputPath}`);
645
- }
646
- const metadata = await getVideoMetadata(inputPath).catch((err) => {
647
- logger.debug(`ffprobe failed: ${err.message}`);
648
- return null;
649
- });
650
- if (!metadata || !metadata.format) {
651
- throw new Error(`Could not read file metadata: ${inputPath}`);
652
- }
653
- const formatName = metadata.format.format_name ?? "";
654
- const duration = parseFloat(metadata.format.duration ?? "0");
655
- const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
656
- if (!hasVideoStream) {
657
- throw new Error(
658
- `No video stream found in file: ${inputPath} (detected format: ${formatName})`
659
- );
660
- }
661
- logger.debug(
662
- `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
663
- );
664
- await ensureDir(framesDir);
665
- let effectiveFps = fps;
666
- if (duration > 0) {
667
- const fpsCap = maxFrames / duration;
668
- effectiveFps = Math.min(fps, fpsCap);
669
- effectiveFps = Math.max(0.5, effectiveFps);
670
- logger.debug(
671
- `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
672
- );
673
- }
674
- const frames = await extractByFps(
675
- inputPath,
676
- framesDir,
677
- effectiveFps,
678
- scale,
679
- duration
680
- );
681
- ctx.emitProgress(100);
682
- logger.debug(`Extracted ${frames.length} frames`);
683
- return frames;
766
+ if (ctx.options.mode === "frames") {
767
+ ctx.effectiveFps = 1;
768
+ return ctx.frames;
769
+ }
770
+ const framesDir = (0, node_path.join)(ctx.workspacePath, "frames");
771
+ const { inputPath, fps, maxFrames, scale } = ctx.options;
772
+ if (!inputPath) throw new Error("inputPath is required for frame extraction");
773
+ if (!await fileExists(inputPath)) throw new Error(`Input file not found: ${inputPath}`);
774
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
775
+ logger.debug(`ffprobe failed: ${err.message}`);
776
+ return null;
777
+ });
778
+ if (!metadata || !metadata.format) throw new Error(`Could not read file metadata: ${inputPath}`);
779
+ const formatName = metadata.format.format_name ?? "";
780
+ const duration = parseFloat(metadata.format.duration ?? "0");
781
+ if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
782
+ logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
783
+ await ensureDir(framesDir);
784
+ const frameLimit = Math.max(2, maxFrames);
785
+ let effectiveFps = fps;
786
+ if (duration > 0) {
787
+ const fpsCap = frameLimit / duration;
788
+ effectiveFps = Math.min(fps, fpsCap);
789
+ logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
790
+ }
791
+ ctx.effectiveFps = effectiveFps;
792
+ ctx.sourceDurationSec = duration;
793
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
794
+ ctx.emitProgress(100);
795
+ logger.debug(`Extracted ${frames.length} frames`);
796
+ return frames;
684
797
  }
685
- async function extractByFps(inputPath, outputDir, fps, scale, duration) {
686
- const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
687
- await (0, import_execa.execa)(import_ffmpeg_static.default, [
688
- "-i",
689
- inputPath,
690
- "-vf",
691
- `fps=${fps},scale=-1:${scale}`,
692
- "-q:v",
693
- "2",
694
- outputPattern
695
- ]);
696
- return buildFrameList(outputDir, duration);
798
+ /**
799
+ * Write scaled JPEG candidates through the bundled FFmpeg runtime.
800
+ * @param inputPath Readable video input.
801
+ * @param outputDir Existing frame directory.
802
+ * @param fps Positive effective sampling frequency.
803
+ * @param scale Output image height.
804
+ * @param frameLimit Maximum number of output frames.
805
+ * @returns Candidates with local output-grid timestamps; rejects on extraction failure.
806
+ */
807
+ async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
808
+ const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
809
+ await (0, execa.execa)(ffmpeg_static.default, [
810
+ "-i",
811
+ inputPath,
812
+ "-vf",
813
+ `fps=${fps},scale=-1:${scale}`,
814
+ "-q:v",
815
+ "2",
816
+ "-frames:v",
817
+ String(frameLimit),
818
+ outputPattern
819
+ ]);
820
+ return buildFrameList(outputDir, fps);
697
821
  }
698
822
  async function getVideoMetadata(inputPath) {
699
- const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
700
- "-v",
701
- "quiet",
702
- "-print_format",
703
- "json",
704
- "-show_format",
705
- "-show_streams",
706
- inputPath
707
- ]);
708
- return JSON.parse(stdout);
823
+ const { stdout } = await (0, execa.execa)(_ffprobe_installer_ffprobe.path, [
824
+ "-v",
825
+ "quiet",
826
+ "-print_format",
827
+ "json",
828
+ "-show_format",
829
+ "-show_streams",
830
+ inputPath
831
+ ]);
832
+ return JSON.parse(stdout);
709
833
  }
710
- async function buildFrameList(framesDir, duration) {
711
- const files = await (0, import_promises2.readdir)(framesDir);
712
- const jpgFiles = (0, import_common_utils2.filter)(files, (f) => f.endsWith(".jpg")).sort();
713
- if (jpgFiles.length === 0) {
714
- return [];
715
- }
716
- return (0, import_common_utils2.map)(jpgFiles, (file, index) => ({
717
- id: index,
718
- timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
719
- extractPath: (0, import_node_path3.join)(framesDir, file)
720
- }));
834
+ /**
835
+ * Read sorted JPEG paths and attach local output-grid times.
836
+ * @param framesDir Extracted frame directory; filesystem errors propagate.
837
+ * @param effectiveFps Positive frequency used by the fps filter.
838
+ * @returns Zero-based candidates without a segment seek offset.
839
+ */
840
+ async function buildFrameList(framesDir, effectiveFps) {
841
+ const jpgFiles = (0, _winglet_common_utils.filter)(await (0, node_fs_promises.readdir)(framesDir), (f) => f.endsWith(".jpg")).sort();
842
+ if (jpgFiles.length === 0) return [];
843
+ return (0, _winglet_common_utils.map)(jpgFiles, (file, index) => ({
844
+ id: index,
845
+ timestamp: index / effectiveFps,
846
+ extractPath: (0, node_path.join)(framesDir, file)
847
+ }));
721
848
  }
722
- async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
723
- const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
724
- await (0, import_execa.execa)(import_ffmpeg_static.default, [
725
- "-ss",
726
- String(startTime),
727
- "-i",
728
- inputPath,
729
- "-t",
730
- String(duration),
731
- "-vf",
732
- `fps=${fps},scale=-1:${scale}`,
733
- "-q:v",
734
- "2",
735
- outputPattern
736
- ]);
737
- return buildFrameList(outputDir, duration);
849
+ /**
850
+ * Extract frames from a specific time range of a video using FFmpeg.
851
+ * Uses input seeking (-ss before -i) for fast seek + -t for duration.
852
+ *
853
+ * @param inputPath - Path to the video file
854
+ * @param outputDir - Directory to write extracted frames
855
+ * @param fps - Frames per second for extraction
856
+ * @param scale - Height scale for vision analysis
857
+ * @param startTime - Start time in seconds
858
+ * @param duration - Duration in seconds to extract
859
+ * @param frameLimit - Positive output limit; defaults to the range's grid capacity
860
+ * @returns Array of FrameNode with segment-local timestamps (starting from 0)
861
+ */
862
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
863
+ const outputPattern = (0, node_path.join)(outputDir, FRAME_FILENAME_PATTERN);
864
+ await (0, execa.execa)(ffmpeg_static.default, [
865
+ "-ss",
866
+ String(startTime),
867
+ "-i",
868
+ inputPath,
869
+ "-t",
870
+ String(duration),
871
+ "-vf",
872
+ `fps=${fps},scale=-1:${scale}`,
873
+ "-q:v",
874
+ "2",
875
+ "-frames:v",
876
+ String(frameLimit),
877
+ outputPattern
878
+ ]);
879
+ return buildFrameList(outputDir, fps);
738
880
  }
881
+ var init_extractor$1 = __esmMin((() => {
882
+ init_workspace_layout();
883
+ init_logger();
884
+ init_paths();
885
+ }));
886
+
887
+ //#endregion
888
+ //#region src/core/extractor/index.ts
889
+ var init_extractor = __esmMin((() => {
890
+ init_extractor$1();
891
+ }));
739
892
 
740
- // src/core/input-resolver.ts
741
- var import_node_path5 = require("path");
893
+ //#endregion
894
+ //#region src/core/input-resolver/validation/validate-options.ts
895
+ /**
896
+ * Reject invalid numeric options before defaults or pipeline effects are applied.
897
+ * @param options - Supplied options; omitted numeric fields use pipeline defaults.
898
+ * @returns Nothing when all supplied numeric fields satisfy their contracts.
899
+ * @throws An input error naming the invalid option and its received value.
900
+ */
901
+ function validateOptions(options) {
902
+ for (const [name, min, max, integer, exclusiveMin, requirement] of [
903
+ [
904
+ "count",
905
+ 1,
906
+ Infinity,
907
+ true,
908
+ false,
909
+ "an integer >= 1"
910
+ ],
911
+ [
912
+ "threshold",
913
+ 0,
914
+ 1,
915
+ false,
916
+ true,
917
+ "in range (0, 1] and finite"
918
+ ],
919
+ [
920
+ "fps",
921
+ 0,
922
+ Infinity,
923
+ false,
924
+ true,
925
+ "finite and > 0"
926
+ ],
927
+ [
928
+ "maxFrames",
929
+ 2,
930
+ Infinity,
931
+ true,
932
+ false,
933
+ "an integer >= 2"
934
+ ],
935
+ [
936
+ "scale",
937
+ 16,
938
+ Infinity,
939
+ true,
940
+ false,
941
+ "an integer >= 16"
942
+ ],
943
+ [
944
+ "quality",
945
+ 1,
946
+ 100,
947
+ true,
948
+ false,
949
+ "an integer in range [1, 100]"
950
+ ],
951
+ [
952
+ "iouThreshold",
953
+ 0,
954
+ 1,
955
+ false,
956
+ false,
957
+ "finite and in range [0, 1]"
958
+ ],
959
+ [
960
+ "animationThreshold",
961
+ 1,
962
+ Infinity,
963
+ true,
964
+ false,
965
+ "an integer >= 1"
966
+ ],
967
+ [
968
+ "maxSegmentDuration",
969
+ 0,
970
+ Infinity,
971
+ false,
972
+ true,
973
+ "finite and > 0"
974
+ ],
975
+ [
976
+ "concurrency",
977
+ 1,
978
+ Infinity,
979
+ true,
980
+ false,
981
+ "an integer >= 1"
982
+ ]
983
+ ]) {
984
+ const value = options[name];
985
+ if (value === void 0) continue;
986
+ if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
987
+ }
988
+ }
989
+ var init_validate_options = __esmMin((() => {}));
742
990
 
743
- // src/core/workspace.ts
744
- var import_promises3 = require("fs/promises");
745
- var import_node_path4 = require("path");
746
- var import_common_utils3 = require("@winglet/common-utils");
747
- var import_sharp2 = __toESM(require("sharp"), 1);
991
+ //#endregion
992
+ //#region src/core/workspace/workspace.ts
748
993
  async function createWorkspace(sessionId) {
749
- const workspacePath = getTempWorkspaceDir(sessionId);
750
- await ensureDir((0, import_node_path4.join)(workspacePath, "frames"));
751
- await ensureDir((0, import_node_path4.join)(workspacePath, "output"));
752
- return workspacePath;
994
+ const workspacePath = getTempWorkspaceDir(sessionId);
995
+ await ensureDir((0, node_path.join)(workspacePath, "frames"));
996
+ await ensureDir((0, node_path.join)(workspacePath, "output"));
997
+ return workspacePath;
753
998
  }
754
999
  async function finalizeOutput(ctx, selectedFrames) {
755
- const stagingDir = (0, import_node_path4.join)(ctx.workspacePath, "output");
756
- const outputPath = ctx.options.outputPath;
757
- const quality = ctx.options.quality;
758
- const outputFiles = [];
759
- const framesMetadata = [];
760
- const totalFramesCount = ctx.frames.length;
761
- const padding = Math.max(4, String(totalFramesCount).length);
762
- for (let i = 0; i < selectedFrames.length; i++) {
763
- const frame = selectedFrames[i];
764
- const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
765
- const destPath = (0, import_node_path4.join)(stagingDir, fileName);
766
- await (0, import_sharp2.default)(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
767
- outputFiles.push((0, import_node_path4.join)(outputPath, fileName));
768
- framesMetadata.push({
769
- step: i + 1,
770
- fileName,
771
- frameId: frame.id + 1,
772
- timestampMs: Math.round(frame.timestamp * 1e3)
773
- });
774
- }
775
- const metadata = {
776
- video: {
777
- originalDurationMs: Math.round(
778
- (ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
779
- ),
780
- fps: ctx.options.fps,
781
- resolution: {
782
- width: ctx.options.scale,
783
- height: Math.round(ctx.options.scale * 9 / 16)
784
- }
785
- },
786
- frames: framesMetadata,
787
- animations: (0, import_common_utils3.map)(ctx.animations || [], (anim) => ({
788
- ...anim,
789
- startFrameId: anim.startFrameId + 1,
790
- endFrameId: anim.endFrameId + 1,
791
- durationMs: Math.round(anim.durationMs)
792
- }))
793
- };
794
- const metadataPath = (0, import_node_path4.join)(stagingDir, ".metadata.json");
795
- await (0, import_promises3.writeFile)(metadataPath, JSON.stringify(metadata, null, 2));
796
- outputFiles.push((0, import_node_path4.join)(outputPath, ".metadata.json"));
797
- await ensureDir((0, import_node_path4.join)(outputPath, ".."));
798
- await (0, import_promises3.rm)(outputPath, { recursive: true, force: true });
799
- await (0, import_promises3.rename)(stagingDir, outputPath);
800
- return outputFiles;
1000
+ const stagingDir = (0, node_path.join)(ctx.workspacePath, "output");
1001
+ const outputPath = ctx.options.outputPath;
1002
+ const quality = ctx.options.quality;
1003
+ const outputFiles = [];
1004
+ const framesMetadata = [];
1005
+ const totalFramesCount = ctx.frames.length;
1006
+ const padding = Math.max(4, String(totalFramesCount).length);
1007
+ for (let i = 0; i < selectedFrames.length; i++) {
1008
+ const frame = selectedFrames[i];
1009
+ const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
1010
+ const destPath = (0, node_path.join)(stagingDir, fileName);
1011
+ await (0, sharp.default)(frame.extractPath).jpeg({
1012
+ quality,
1013
+ mozjpeg: true
1014
+ }).toFile(destPath);
1015
+ outputFiles.push((0, node_path.join)(outputPath, fileName));
1016
+ framesMetadata.push({
1017
+ step: i + 1,
1018
+ fileName,
1019
+ frameId: frame.id + 1,
1020
+ timestampMs: Math.round(frame.timestamp * 1e3)
1021
+ });
1022
+ }
1023
+ const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
1024
+ const metadata = {
1025
+ video,
1026
+ frames: framesMetadata,
1027
+ animations: (0, _winglet_common_utils.map)(animations, (anim) => ({
1028
+ ...anim,
1029
+ startFrameId: anim.startFrameId + 1,
1030
+ endFrameId: anim.endFrameId + 1,
1031
+ durationMs: Math.round(anim.durationMs)
1032
+ }))
1033
+ };
1034
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(stagingDir, ".metadata.json"), JSON.stringify(metadata, null, 2));
1035
+ outputFiles.push((0, node_path.join)(outputPath, ".metadata.json"));
1036
+ await ensureDir((0, node_path.join)(outputPath, ".."));
1037
+ await (0, node_fs_promises.rm)(outputPath, {
1038
+ recursive: true,
1039
+ force: true
1040
+ });
1041
+ await (0, node_fs_promises.rename)(stagingDir, outputPath);
1042
+ return outputFiles;
801
1043
  }
802
1044
  async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
803
- const segmentPath = (0, import_node_path4.join)(
804
- parentWorkspacePath,
805
- "segments",
806
- String(segmentIndex)
807
- );
808
- await ensureDir((0, import_node_path4.join)(segmentPath, "frames"));
809
- return segmentPath;
1045
+ const segmentPath = (0, node_path.join)(parentWorkspacePath, "segments", String(segmentIndex));
1046
+ await ensureDir((0, node_path.join)(segmentPath, "frames"));
1047
+ return segmentPath;
810
1048
  }
811
1049
  async function cleanupWorkspace(workspacePath) {
812
- if (!workspacePath) return;
813
- try {
814
- await (0, import_promises3.rm)(workspacePath, { recursive: true, force: true });
815
- } catch {
816
- }
1050
+ if (!workspacePath) return;
1051
+ try {
1052
+ await (0, node_fs_promises.rm)(workspacePath, {
1053
+ recursive: true,
1054
+ force: true
1055
+ });
1056
+ } catch {}
817
1057
  }
818
- var STALE_THRESHOLD_MS = 60 * 60 * 1e3;
1058
+ /**
1059
+ * Write a video buffer to a temp file in the workspace and return the path.
1060
+ * Used by 'buffer' input mode.
1061
+ */
819
1062
  async function writeInputBuffer(buffer, workspacePath) {
820
- const inputDir = (0, import_node_path4.join)(workspacePath, "input");
821
- await ensureDir(inputDir);
822
- const tempPath = (0, import_node_path4.join)(inputDir, "input.mp4");
823
- await (0, import_promises3.writeFile)(tempPath, buffer);
824
- return tempPath;
1063
+ const inputDir = (0, node_path.join)(workspacePath, "input");
1064
+ await ensureDir(inputDir);
1065
+ const tempPath = (0, node_path.join)(inputDir, "input.mp4");
1066
+ await (0, node_fs_promises.writeFile)(tempPath, buffer);
1067
+ return tempPath;
825
1068
  }
1069
+ /**
1070
+ * Write an array of frame Buffers as JPG files and return FrameNode[].
1071
+ * Used by 'frames' input mode.
1072
+ */
826
1073
  async function writeInputFrames(frames, workspacePath) {
827
- const framesDir = (0, import_node_path4.join)(workspacePath, "frames");
828
- await ensureDir(framesDir);
829
- const frameNodes = [];
830
- for (let i = 0; i < frames.length; i++) {
831
- const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
832
- const extractPath = (0, import_node_path4.join)(framesDir, filename);
833
- await (0, import_promises3.writeFile)(extractPath, frames[i]);
834
- frameNodes.push({ id: i, timestamp: i, extractPath });
835
- }
836
- return frameNodes;
1074
+ const framesDir = (0, node_path.join)(workspacePath, "frames");
1075
+ await ensureDir(framesDir);
1076
+ const frameNodes = [];
1077
+ for (let i = 0; i < frames.length; i++) {
1078
+ const extractPath = (0, node_path.join)(framesDir, `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`);
1079
+ await (0, node_fs_promises.writeFile)(extractPath, frames[i]);
1080
+ frameNodes.push({
1081
+ id: i,
1082
+ timestamp: i,
1083
+ extractPath
1084
+ });
1085
+ }
1086
+ return frameNodes;
837
1087
  }
1088
+ /**
1089
+ * Read selected FrameNode files as Buffers with JPEG compression.
1090
+ * Used to return output buffers in 'buffer' and 'frames' modes.
1091
+ */
838
1092
  async function readFramesAsBuffers(frameNodes, quality) {
839
- return Promise.all(
840
- (0, import_common_utils3.map)(
841
- frameNodes,
842
- (f) => (0, import_sharp2.default)(f.extractPath).jpeg({ quality, mozjpeg: true }).toBuffer()
843
- )
844
- );
1093
+ return Promise.all((0, _winglet_common_utils.map)(frameNodes, (f) => (0, sharp.default)(f.extractPath).jpeg({
1094
+ quality,
1095
+ mozjpeg: true
1096
+ }).toBuffer()));
845
1097
  }
1098
+ var init_workspace$1 = __esmMin((() => {
1099
+ init_workspace_layout();
1100
+ init_paths();
1101
+ init_build_video_metadata();
1102
+ }));
846
1103
 
847
- // src/core/input-resolver.ts
1104
+ //#endregion
1105
+ //#region src/core/workspace/index.ts
1106
+ var init_workspace = __esmMin((() => {
1107
+ init_workspace$1();
1108
+ }));
1109
+
1110
+ //#endregion
1111
+ //#region src/core/input-resolver/input-resolver.ts
1112
+ /**
1113
+ * Validate supplied options and resolve defaults and paths for the pipeline.
1114
+ * @param options - Mode-specific input and optional numeric settings.
1115
+ * @returns Complete pipeline settings with absolute file input paths.
1116
+ * @throws An input error if a supplied numeric setting is invalid.
1117
+ */
848
1118
  function resolveOptions(options) {
849
- const mode = options.mode;
850
- const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
851
- const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, import_node_path5.join)(process.cwd(), "scene-sieve-output"));
852
- const threshold = options.threshold ?? DEFAULT_THRESHOLD;
853
- if (threshold <= 0 || threshold > 1) {
854
- throw new Error(
855
- `threshold must be in range (0, 1], received: ${threshold}`
856
- );
857
- }
858
- const pruneMode = "threshold-with-cap";
859
- return {
860
- mode,
861
- inputPath,
862
- count: options.count ?? DEFAULT_COUNT,
863
- threshold,
864
- pruneMode,
865
- outputPath,
866
- fps: options.fps ?? DEFAULT_FPS,
867
- maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
868
- scale: options.scale ?? DEFAULT_SCALE,
869
- quality: options.quality ?? DEFAULT_QUALITY,
870
- iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
871
- animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
872
- debug: options.debug ?? false,
873
- maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
874
- concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
875
- };
1119
+ validateOptions(options);
1120
+ const mode = options.mode;
1121
+ const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
1122
+ const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : (0, node_path.join)(process.cwd(), "scene-sieve-output"));
1123
+ const threshold = options.threshold ?? .5;
1124
+ return {
1125
+ mode,
1126
+ inputPath,
1127
+ count: options.count ?? 20,
1128
+ threshold,
1129
+ pruneMode: "threshold-with-cap",
1130
+ outputPath,
1131
+ fps: options.fps ?? 5,
1132
+ maxFrames: options.maxFrames ?? 300,
1133
+ scale: options.scale ?? 720,
1134
+ quality: options.quality ?? 80,
1135
+ iouThreshold: options.iouThreshold ?? .9,
1136
+ animationThreshold: options.animationThreshold ?? 5,
1137
+ debug: options.debug ?? false,
1138
+ maxSegmentDuration: options.maxSegmentDuration ?? 300,
1139
+ concurrency: options.concurrency ?? 2
1140
+ };
876
1141
  }
1142
+ /**
1143
+ * Resolve the input source to a list of FrameNode[].
1144
+ *
1145
+ * - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
1146
+ * - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
1147
+ * - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
1148
+ * @param options - Input source; encoded frames must have matching dimensions.
1149
+ * @param workspacePath - Workspace receiving temporary input files.
1150
+ * @returns Frame nodes or a resolved video path for extraction.
1151
+ * @throws Propagates metadata or write errors and rejects mismatched frame sizes.
1152
+ */
877
1153
  async function resolveInput(options, workspacePath) {
878
- if (options.mode === "file") {
879
- return {
880
- frames: [],
881
- resolvedInputPath: resolveAbsolute(options.inputPath)
882
- };
883
- }
884
- if (options.mode === "buffer") {
885
- const resolvedInputPath = await writeInputBuffer(
886
- options.inputBuffer,
887
- workspacePath
888
- );
889
- return { frames: [], resolvedInputPath };
890
- }
891
- if (options.mode === "frames") {
892
- const frames = await writeInputFrames(options.inputFrames, workspacePath);
893
- return { frames };
894
- }
895
- throw new Error(`Unsupported input mode: ${options.mode}`);
1154
+ if (options.mode === "file") return {
1155
+ frames: [],
1156
+ resolvedInputPath: resolveAbsolute(options.inputPath)
1157
+ };
1158
+ if (options.mode === "buffer") return {
1159
+ frames: [],
1160
+ resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
1161
+ };
1162
+ if (options.mode === "frames") {
1163
+ let dimensions;
1164
+ for (const buffer of options.inputFrames) {
1165
+ const { width, height } = await (0, sharp.default)(buffer).metadata();
1166
+ if (dimensions && (width !== dimensions.width || height !== dimensions.height)) throw new Error(`inputFrames must be the same size (${dimensions.width}x${dimensions.height}), received: ${width}x${height}`);
1167
+ dimensions = {
1168
+ width,
1169
+ height
1170
+ };
1171
+ }
1172
+ return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1173
+ }
1174
+ throw new Error(`Unsupported input mode: ${options.mode}`);
896
1175
  }
1176
+ var init_input_resolver$1 = __esmMin((() => {
1177
+ init_pipeline_defaults();
1178
+ init_paths();
1179
+ init_validate_options();
1180
+ init_workspace();
1181
+ }));
897
1182
 
898
- // src/core/pruner.ts
899
- var import_common_utils5 = require("@winglet/common-utils");
1183
+ //#endregion
1184
+ //#region src/core/input-resolver/index.ts
1185
+ var init_input_resolver = __esmMin((() => {
1186
+ init_input_resolver$1();
1187
+ }));
900
1188
 
901
- // src/utils/math.ts
902
- var import_common_utils4 = require("@winglet/common-utils");
1189
+ //#endregion
1190
+ //#region src/core/pruner/scoring/normalize-scores.ts
1191
+ /**
1192
+ * Find the first position whose score is at least the requested value.
1193
+ * @param sorted - Finite positive scores sorted in ascending order.
1194
+ * @param value - A finite positive score present in sorted.
1195
+ * @returns The first matching rank, including the first position of any tie.
1196
+ */
1197
+ function lowerBound(sorted, value) {
1198
+ let low = 0;
1199
+ let high = sorted.length;
1200
+ while (low < high) {
1201
+ const mid = Math.floor((low + high) / 2);
1202
+ if (sorted[mid] < value) low = mid + 1;
1203
+ else high = mid;
1204
+ }
1205
+ return low;
1206
+ }
1207
+ /**
1208
+ * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
1209
+ *
1210
+ * This model combines two mathematical approaches to provide a stable "relative" threshold:
1211
+ *
1212
+ * 1. Logistic-Robust-Z (Intensity):
1213
+ * Calculates Z-scores using Median and Median Absolute Deviation (MAD).
1214
+ * Maps these to a sigmoid (logistic) curve. This suppresses noise (scores near median)
1215
+ * and highlights significant signals (outliers) without letting extreme outliers
1216
+ * crush other meaningful transitions.
1217
+ *
1218
+ * 2. CDF / Percentile Rank (Relative Position):
1219
+ * Maps each score to its percentile rank in the sequence. This ensures that 't'
1220
+ * always has a consistent meaning as a "relative rank" regardless of absolute values.
1221
+ *
1222
+ * The final score is a weighted sum (NORMALIZATION_ALPHA) of both.
1223
+ *
1224
+ * @param items - Array of items with scores to normalize
1225
+ * @returns normalized scores array (same length as input)
1226
+ */
903
1227
  function normalizeScores(items) {
904
- if (items.length === 0) return [];
905
- const safeScores = (0, import_common_utils4.map)(
906
- items,
907
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
908
- );
909
- const positiveScores = (0, import_common_utils4.filter)(safeScores, (s) => s > 0);
910
- if (positiveScores.length === 0) return safeScores;
911
- const sorted = [...positiveScores].sort((a, b) => a - b);
912
- if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
913
- const min = sorted[0];
914
- const max = sorted[sorted.length - 1];
915
- if (max === min) return (0, import_common_utils4.map)(safeScores, (s) => s > 0 ? 1 : 0);
916
- return (0, import_common_utils4.map)(
917
- safeScores,
918
- (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
919
- );
920
- }
921
- const median = sorted[Math.floor(sorted.length / 2)];
922
- const absoluteDiffs = (0, import_common_utils4.map)(positiveScores, (v) => Math.abs(v - median));
923
- const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
924
- const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
925
- const logisticZ = (0, import_common_utils4.map)(safeScores, (s) => {
926
- if (s <= 0) return 0;
927
- if (scale === 0) return 1;
928
- const z = (s - median) / scale;
929
- return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
930
- });
931
- const cdf = (0, import_common_utils4.map)(safeScores, (s) => {
932
- if (s <= 0) return 0;
933
- const rank = sorted.findIndex((v) => v >= s);
934
- return rank / sorted.length;
935
- });
936
- return (0, import_common_utils4.map)(
937
- logisticZ,
938
- (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
939
- );
1228
+ if (items.length === 0) return [];
1229
+ const safeScores = (0, _winglet_common_utils.map)(items, (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0);
1230
+ const positiveScores = (0, _winglet_common_utils.filter)(safeScores, (s) => s > 0);
1231
+ if (positiveScores.length === 0) return safeScores;
1232
+ const sorted = [...positiveScores].sort((a, b) => a - b);
1233
+ if (positiveScores.length <= 10) {
1234
+ const min = sorted[0];
1235
+ const max = sorted[sorted.length - 1];
1236
+ if (max === min) return (0, _winglet_common_utils.map)(safeScores, (s) => s > 0 ? 1 : 0);
1237
+ return (0, _winglet_common_utils.map)(safeScores, (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1)));
1238
+ }
1239
+ const median = sorted[Math.floor(sorted.length / 2)];
1240
+ const absoluteDiffs = (0, _winglet_common_utils.map)(positiveScores, (v) => Math.abs(v - median));
1241
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
1242
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
1243
+ const logisticZ = (0, _winglet_common_utils.map)(safeScores, (s) => {
1244
+ if (s <= 0) return 0;
1245
+ if (scale === 0) return 1;
1246
+ const z = (s - median) / scale;
1247
+ return 1 / (1 + Math.exp(-3 * z));
1248
+ });
1249
+ const cdf = (0, _winglet_common_utils.map)(safeScores, (s) => {
1250
+ if (s <= 0) return 0;
1251
+ return lowerBound(sorted, s) / sorted.length;
1252
+ });
1253
+ return (0, _winglet_common_utils.map)(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
940
1254
  }
1255
+ var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1256
+ var init_normalize_scores = __esmMin((() => {
1257
+ NORMALIZATION_ALPHA = .4;
1258
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
1259
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
1260
+ }));
941
1261
 
942
- // src/utils/min-heap.ts
943
- var MinHeap = class {
944
- h = [];
945
- get size() {
946
- return this.h.length;
947
- }
948
- push(entry) {
949
- this.h.push(entry);
950
- this.siftUp(this.h.length - 1);
951
- }
952
- pop() {
953
- const n = this.h.length;
954
- if (n === 0) return void 0;
955
- const top = this.h[0];
956
- const last = this.h.pop();
957
- if (n > 1) {
958
- this.h[0] = last;
959
- this.siftDown(0);
960
- }
961
- return top;
962
- }
963
- siftUp(i) {
964
- while (i > 0) {
965
- const p = i - 1 >> 1;
966
- if (this.h[p].score <= this.h[i].score) break;
967
- [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
968
- i = p;
969
- }
970
- }
971
- siftDown(i) {
972
- const n = this.h.length;
973
- for (; ; ) {
974
- let m = i;
975
- const l = 2 * i + 1;
976
- const r = 2 * i + 2;
977
- if (l < n && this.h[l].score < this.h[m].score) m = l;
978
- if (r < n && this.h[r].score < this.h[m].score) m = r;
979
- if (m === i) break;
980
- [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
981
- i = m;
982
- }
983
- }
984
- };
1262
+ //#endregion
1263
+ //#region src/core/pruner/heap/min-heap.ts
1264
+ var MinHeap;
1265
+ var init_min_heap = __esmMin((() => {
1266
+ MinHeap = class {
1267
+ h = [];
1268
+ get size() {
1269
+ return this.h.length;
1270
+ }
1271
+ push(entry) {
1272
+ this.h.push(entry);
1273
+ this.siftUp(this.h.length - 1);
1274
+ }
1275
+ pop() {
1276
+ const n = this.h.length;
1277
+ if (n === 0) return void 0;
1278
+ const top = this.h[0];
1279
+ const last = this.h.pop();
1280
+ if (n > 1) {
1281
+ this.h[0] = last;
1282
+ this.siftDown(0);
1283
+ }
1284
+ return top;
1285
+ }
1286
+ siftUp(i) {
1287
+ while (i > 0) {
1288
+ const p = i - 1 >> 1;
1289
+ if (this.h[p].score <= this.h[i].score) break;
1290
+ [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
1291
+ i = p;
1292
+ }
1293
+ }
1294
+ siftDown(i) {
1295
+ const n = this.h.length;
1296
+ for (;;) {
1297
+ let m = i;
1298
+ const l = 2 * i + 1;
1299
+ const r = 2 * i + 2;
1300
+ if (l < n && this.h[l].score < this.h[m].score) m = l;
1301
+ if (r < n && this.h[r].score < this.h[m].score) m = r;
1302
+ if (m === i) break;
1303
+ [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
1304
+ i = m;
1305
+ }
1306
+ }
1307
+ };
1308
+ }));
985
1309
 
986
- // src/core/pruner.ts
1310
+ //#endregion
1311
+ //#region src/core/pruner/pruner.ts
1312
+ /**
1313
+ * Edge-aware greedy merge with re-linking — O(N log N).
1314
+ *
1315
+ * 1. Build a doubly-linked list of frames
1316
+ * 2. Insert all edges into a min-heap
1317
+ * 3. Pop the lowest-score edge (most similar pair)
1318
+ * 4. Remove the later frame (tgtId), re-link neighbors
1319
+ * 5. Push synthetic edge with score = max(left, right)
1320
+ * 6. Repeat until surviving count === targetCount
1321
+ * 7. First and last frames are never removed (boundary preservation)
1322
+ *
1323
+ * Stale heap entries (involving removed frames) are lazily skipped on pop.
1324
+ */
987
1325
  function pruneTo(graph, frames, targetCount) {
988
- if (frames.length <= targetCount) {
989
- return new Set((0, import_common_utils5.map)(frames, (f) => f.id));
990
- }
991
- const prev = /* @__PURE__ */ new Map();
992
- const next = /* @__PURE__ */ new Map();
993
- for (let i = 0; i < frames.length; i++) {
994
- if (i > 0) prev.set(frames[i].id, frames[i - 1].id);
995
- if (i < frames.length - 1) next.set(frames[i].id, frames[i + 1].id);
996
- }
997
- const edgeScore = /* @__PURE__ */ new Map();
998
- const heap = new MinHeap();
999
- for (const edge of graph) {
1000
- edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
1001
- heap.push({
1002
- score: edge.score,
1003
- srcId: edge.sourceId,
1004
- tgtId: edge.targetId
1005
- });
1006
- }
1007
- const surviving = new Set((0, import_common_utils5.map)(frames, (f) => f.id));
1008
- const firstId = frames[0].id;
1009
- const lastId = frames[frames.length - 1].id;
1010
- while (surviving.size > targetCount && heap.size > 0) {
1011
- const entry = heap.pop();
1012
- if (!surviving.has(entry.srcId) || !surviving.has(entry.tgtId)) continue;
1013
- const key = `${entry.srcId}:${entry.tgtId}`;
1014
- if (edgeScore.get(key) !== entry.score) continue;
1015
- if (entry.tgtId === firstId || entry.tgtId === lastId) continue;
1016
- surviving.delete(entry.tgtId);
1017
- edgeScore.delete(key);
1018
- const tgtNext = next.get(entry.tgtId);
1019
- if (tgtNext !== void 0) {
1020
- const rightKey = `${entry.tgtId}:${tgtNext}`;
1021
- const rightScore = edgeScore.get(rightKey) ?? 0;
1022
- edgeScore.delete(rightKey);
1023
- const newScore = Math.max(entry.score, rightScore);
1024
- edgeScore.set(`${entry.srcId}:${tgtNext}`, newScore);
1025
- heap.push({ score: newScore, srcId: entry.srcId, tgtId: tgtNext });
1026
- next.set(entry.srcId, tgtNext);
1027
- prev.set(tgtNext, entry.srcId);
1028
- } else {
1029
- next.delete(entry.srcId);
1030
- }
1031
- prev.delete(entry.tgtId);
1032
- next.delete(entry.tgtId);
1033
- }
1034
- return surviving;
1326
+ if (frames.length <= targetCount) return new Set((0, _winglet_common_utils.map)(frames, (f) => f.id));
1327
+ const prev = /* @__PURE__ */ new Map();
1328
+ const next = /* @__PURE__ */ new Map();
1329
+ for (let i = 0; i < frames.length; i++) {
1330
+ if (i > 0) prev.set(frames[i].id, frames[i - 1].id);
1331
+ if (i < frames.length - 1) next.set(frames[i].id, frames[i + 1].id);
1332
+ }
1333
+ const edgeScore = /* @__PURE__ */ new Map();
1334
+ const heap = new MinHeap();
1335
+ for (const edge of graph) {
1336
+ edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
1337
+ heap.push({
1338
+ score: edge.score,
1339
+ srcId: edge.sourceId,
1340
+ tgtId: edge.targetId
1341
+ });
1342
+ }
1343
+ const surviving = new Set((0, _winglet_common_utils.map)(frames, (f) => f.id));
1344
+ const firstId = frames[0].id;
1345
+ const lastId = frames[frames.length - 1].id;
1346
+ while (surviving.size > targetCount && heap.size > 0) {
1347
+ const entry = heap.pop();
1348
+ if (!surviving.has(entry.srcId) || !surviving.has(entry.tgtId)) continue;
1349
+ const key = `${entry.srcId}:${entry.tgtId}`;
1350
+ if (edgeScore.get(key) !== entry.score) continue;
1351
+ if (entry.tgtId === firstId || entry.tgtId === lastId) continue;
1352
+ surviving.delete(entry.tgtId);
1353
+ edgeScore.delete(key);
1354
+ const tgtNext = next.get(entry.tgtId);
1355
+ if (tgtNext !== void 0) {
1356
+ const rightKey = `${entry.tgtId}:${tgtNext}`;
1357
+ const rightScore = edgeScore.get(rightKey) ?? 0;
1358
+ edgeScore.delete(rightKey);
1359
+ const newScore = Math.max(entry.score, rightScore);
1360
+ edgeScore.set(`${entry.srcId}:${tgtNext}`, newScore);
1361
+ heap.push({
1362
+ score: newScore,
1363
+ srcId: entry.srcId,
1364
+ tgtId: tgtNext
1365
+ });
1366
+ next.set(entry.srcId, tgtNext);
1367
+ prev.set(tgtNext, entry.srcId);
1368
+ } else next.delete(entry.srcId);
1369
+ prev.delete(entry.tgtId);
1370
+ next.delete(entry.tgtId);
1371
+ }
1372
+ return surviving;
1035
1373
  }
1374
+ /**
1375
+ * Non-Maximum Suppression (NMS) for consecutive edge runs.
1376
+ *
1377
+ * Consecutive edges share overlapping frames (edge i: frame i->i+1,
1378
+ * edge i+1: frame i+1->i+2), so consecutive passing edges indicate
1379
+ * the same visual transition region. This function groups consecutive
1380
+ * passing edge indices into "runs" and keeps all distinct peaks per run.
1381
+ *
1382
+ * Multi-peak detection: within each run, strict local maxima (score higher
1383
+ * than both neighbors) are identified. Each local maximum represents a
1384
+ * distinct visual transition. If no strict local maxima exist (plateau or
1385
+ * monotonic sequence), the global peak of the run is selected as fallback.
1386
+ *
1387
+ * Single-element runs are unaffected (isolated transitions preserved).
1388
+ *
1389
+ * @param graph - full ScoreEdge array (for targetId lookup)
1390
+ * @param passingIndices - edge indices that passed threshold filtering (sorted ascending)
1391
+ * @param normalizedScores - normalized score array (same length as graph)
1392
+ * @returns Set of targetIds to add to surviving set (one or more per run)
1393
+ */
1036
1394
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
1037
- const result = /* @__PURE__ */ new Set();
1038
- let runStart = 0;
1039
- while (runStart < passingIndices.length) {
1040
- let runEnd = runStart;
1041
- while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
1042
- runEnd++;
1043
- }
1044
- const runLen = runEnd - runStart + 1;
1045
- if (runLen === 1) {
1046
- result.add(graph[passingIndices[runStart]].targetId);
1047
- } else {
1048
- const peaks = [];
1049
- for (let j = runStart; j <= runEnd; j++) {
1050
- const idx = passingIndices[j];
1051
- const score = normalizedScores[idx];
1052
- const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
1053
- const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
1054
- if (score > prevScore && score > nextScore) {
1055
- peaks.push(idx);
1056
- }
1057
- }
1058
- if (peaks.length > 0) {
1059
- for (const peakIdx of peaks) {
1060
- result.add(graph[peakIdx].targetId);
1061
- }
1062
- } else {
1063
- let peakIdx = passingIndices[runStart];
1064
- for (let j = runStart + 1; j <= runEnd; j++) {
1065
- const idx = passingIndices[j];
1066
- if (normalizedScores[idx] > normalizedScores[peakIdx]) {
1067
- peakIdx = idx;
1068
- }
1069
- }
1070
- result.add(graph[peakIdx].targetId);
1071
- }
1072
- }
1073
- runStart = runEnd + 1;
1074
- }
1075
- return result;
1395
+ const result = /* @__PURE__ */ new Set();
1396
+ let runStart = 0;
1397
+ while (runStart < passingIndices.length) {
1398
+ let runEnd = runStart;
1399
+ while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) runEnd++;
1400
+ if (runEnd - runStart + 1 === 1) result.add(graph[passingIndices[runStart]].targetId);
1401
+ else {
1402
+ const peaks = [];
1403
+ for (let j = runStart; j <= runEnd; j++) {
1404
+ const idx = passingIndices[j];
1405
+ const score = normalizedScores[idx];
1406
+ const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
1407
+ const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
1408
+ if (score > prevScore && score > nextScore) peaks.push(idx);
1409
+ }
1410
+ if (peaks.length > 0) for (const peakIdx of peaks) result.add(graph[peakIdx].targetId);
1411
+ else {
1412
+ let peakIdx = passingIndices[runStart];
1413
+ for (let j = runStart + 1; j <= runEnd; j++) {
1414
+ const idx = passingIndices[j];
1415
+ if (normalizedScores[idx] > normalizedScores[peakIdx]) peakIdx = idx;
1416
+ }
1417
+ result.add(graph[peakIdx].targetId);
1418
+ }
1419
+ }
1420
+ runStart = runEnd + 1;
1421
+ }
1422
+ return result;
1076
1423
  }
1424
+ /**
1425
+ * Threshold-based pruning with NMS -- including normalization, O(N log N).
1426
+ *
1427
+ * 1. Scores are normalized to [0, 1] via percentile normalization.
1428
+ * 2. Edges with normalized score >= threshold are collected.
1429
+ * 3. Non-Maximum Suppression groups consecutive passing edges and keeps
1430
+ * only the peak per run, preventing near-duplicate frame selection
1431
+ * from a single visual transition.
1432
+ *
1433
+ * First and last frames are always preserved (boundary protection).
1434
+ */
1077
1435
  function pruneByThreshold(graph, frames, threshold) {
1078
- if (frames.length === 0) return /* @__PURE__ */ new Set();
1079
- const surviving = /* @__PURE__ */ new Set();
1080
- surviving.add(frames[0].id);
1081
- surviving.add(frames[frames.length - 1].id);
1082
- const normalized = normalizeScores(graph);
1083
- const passingIndices = [];
1084
- for (let i = 0; i < graph.length; i++) {
1085
- if (normalized[i] >= threshold) {
1086
- passingIndices.push(i);
1087
- }
1088
- }
1089
- const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
1090
- for (const id of nmsTargets) {
1091
- surviving.add(id);
1092
- }
1093
- return surviving;
1436
+ if (frames.length === 0) return /* @__PURE__ */ new Set();
1437
+ const surviving = /* @__PURE__ */ new Set();
1438
+ surviving.add(frames[0].id);
1439
+ surviving.add(frames[frames.length - 1].id);
1440
+ const normalized = normalizeScores(graph);
1441
+ const passingIndices = [];
1442
+ for (let i = 0; i < graph.length; i++) if (normalized[i] >= threshold) passingIndices.push(i);
1443
+ const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
1444
+ for (const id of nmsTargets) surviving.add(id);
1445
+ return surviving;
1094
1446
  }
1447
+ /**
1448
+ * Combined threshold + count pruning -- 2-stage pipeline.
1449
+ *
1450
+ * Stage 1: pruneByThreshold -- keep all frames with normalized score >= threshold
1451
+ * Stage 2: if result exceeds maxCount, rebuild subgraph with synthetic edges
1452
+ * (min-score over each gap) and apply pruneTo on the surviving subset
1453
+ *
1454
+ * Edge reconstruction: for consecutive survivors A, B with removed frames
1455
+ * [x1, x2, ...] between them, the synthetic edge score is:
1456
+ * min(score(A->x1), score(x1->x2), ..., score(xN->B))
1457
+ * This preserves the "weakest link" semantics.
1458
+ */
1095
1459
  function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1096
- const thresholdSurvivors = pruneByThreshold(graph, frames, threshold);
1097
- if (thresholdSurvivors.size <= maxCount) {
1098
- return thresholdSurvivors;
1099
- }
1100
- const survivingFrames = (0, import_common_utils5.filter)(frames, (f) => thresholdSurvivors.has(f.id));
1101
- const idToOrigIdx = /* @__PURE__ */ new Map();
1102
- for (let i = 0; i < frames.length; i++) {
1103
- idToOrigIdx.set(frames[i].id, i);
1104
- }
1105
- const edgeLookup = /* @__PURE__ */ new Map();
1106
- for (const e of graph) {
1107
- edgeLookup.set(`${e.sourceId}:${e.targetId}`, e.score);
1108
- }
1109
- const syntheticEdges = [];
1110
- for (let i = 0; i < survivingFrames.length - 1; i++) {
1111
- const srcSurvivor = survivingFrames[i];
1112
- const tgtSurvivor = survivingFrames[i + 1];
1113
- const srcOrigIdx = idToOrigIdx.get(srcSurvivor.id);
1114
- const tgtOrigIdx = idToOrigIdx.get(tgtSurvivor.id);
1115
- let minScore = Infinity;
1116
- for (let j = srcOrigIdx; j < tgtOrigIdx; j++) {
1117
- const fromId = frames[j].id;
1118
- const toId = frames[j + 1].id;
1119
- const score = edgeLookup.get(`${fromId}:${toId}`) ?? 0;
1120
- if (score < minScore) {
1121
- minScore = score;
1122
- }
1123
- }
1124
- syntheticEdges.push({
1125
- sourceId: srcSurvivor.id,
1126
- targetId: tgtSurvivor.id,
1127
- score: minScore === Infinity ? 0 : minScore
1128
- });
1129
- }
1130
- return pruneTo(syntheticEdges, survivingFrames, maxCount);
1460
+ const thresholdSurvivors = pruneByThreshold(graph, frames, threshold);
1461
+ if (thresholdSurvivors.size <= maxCount) return thresholdSurvivors;
1462
+ const survivingFrames = (0, _winglet_common_utils.filter)(frames, (f) => thresholdSurvivors.has(f.id));
1463
+ const idToOrigIdx = /* @__PURE__ */ new Map();
1464
+ for (let i = 0; i < frames.length; i++) idToOrigIdx.set(frames[i].id, i);
1465
+ const edgeLookup = /* @__PURE__ */ new Map();
1466
+ for (const e of graph) edgeLookup.set(`${e.sourceId}:${e.targetId}`, e.score);
1467
+ const syntheticEdges = [];
1468
+ for (let i = 0; i < survivingFrames.length - 1; i++) {
1469
+ const srcSurvivor = survivingFrames[i];
1470
+ const tgtSurvivor = survivingFrames[i + 1];
1471
+ const srcOrigIdx = idToOrigIdx.get(srcSurvivor.id);
1472
+ const tgtOrigIdx = idToOrigIdx.get(tgtSurvivor.id);
1473
+ let minScore = Infinity;
1474
+ for (let j = srcOrigIdx; j < tgtOrigIdx; j++) {
1475
+ const fromId = frames[j].id;
1476
+ const toId = frames[j + 1].id;
1477
+ const score = edgeLookup.get(`${fromId}:${toId}`) ?? 0;
1478
+ if (score < minScore) minScore = score;
1479
+ }
1480
+ syntheticEdges.push({
1481
+ sourceId: srcSurvivor.id,
1482
+ targetId: tgtSurvivor.id,
1483
+ score: minScore === Infinity ? 0 : minScore
1484
+ });
1485
+ }
1486
+ return pruneTo(syntheticEdges, survivingFrames, maxCount);
1131
1487
  }
1488
+ var init_pruner$1 = __esmMin((() => {
1489
+ init_normalize_scores();
1490
+ init_min_heap();
1491
+ }));
1132
1492
 
1133
- // src/core/segmenter.ts
1134
- var import_node_crypto = require("crypto");
1135
- var import_node_path6 = require("path");
1136
- var import_common_utils6 = require("@winglet/common-utils");
1493
+ //#endregion
1494
+ //#region src/core/pruner/index.ts
1495
+ var init_pruner = __esmMin((() => {
1496
+ init_pruner$1();
1497
+ }));
1137
1498
 
1138
- // src/utils/concurrency.ts
1499
+ //#endregion
1500
+ //#region src/core/segmenter/scheduling/concurrency.ts
1501
+ /**
1502
+ * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
1503
+ * Lightweight replacement for p-limit to avoid external dependency.
1504
+ */
1139
1505
  function concurrencyLimit(limit) {
1140
- limit = Math.max(1, limit);
1141
- let active = 0;
1142
- const queue = [];
1143
- return async (fn) => {
1144
- while (active >= limit) {
1145
- await new Promise((resolve2) => queue.push(resolve2));
1146
- }
1147
- active++;
1148
- try {
1149
- return await fn();
1150
- } finally {
1151
- active--;
1152
- queue.shift()?.();
1153
- }
1154
- };
1506
+ limit = Math.max(1, limit);
1507
+ let active = 0;
1508
+ const queue = [];
1509
+ return async (fn) => {
1510
+ while (active >= limit) await new Promise((resolve) => queue.push(resolve));
1511
+ active++;
1512
+ try {
1513
+ return await fn();
1514
+ } finally {
1515
+ active--;
1516
+ queue.shift()?.();
1517
+ }
1518
+ };
1155
1519
  }
1520
+ var init_concurrency = __esmMin((() => {}));
1156
1521
 
1157
- // src/core/segmenter.ts
1522
+ //#endregion
1523
+ //#region src/core/segmenter/segmenter.ts
1524
+ /**
1525
+ * Determine whether segmentation should be used.
1526
+ * Returns false for frames mode and GIF files.
1527
+ * Actual duration check happens inside runSegmentedPipeline after metadata fetch.
1528
+ */
1158
1529
  function shouldSegment(resolvedOptions, originalOptions) {
1159
- if (resolvedOptions.mode === "frames") return false;
1160
- if (originalOptions.mode === "file") {
1161
- if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
1162
- }
1163
- return true;
1530
+ if (resolvedOptions.mode === "frames") return false;
1531
+ if (originalOptions.mode === "file") {
1532
+ if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
1533
+ }
1534
+ return true;
1164
1535
  }
1536
+ /**
1537
+ * Partition the global extraction grid into nonempty logical segments.
1538
+ * @param totalDuration Positive source duration in seconds.
1539
+ * @param maxSegmentDuration Positive logical segment width in seconds.
1540
+ * @param maxFrames Candidate budget, defensively raised to at least two.
1541
+ * @param fps Positive requested sampling frequency.
1542
+ * @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
1543
+ */
1165
1544
  function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1166
- const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
1167
- if (totalDuration <= maxSegmentDuration) {
1168
- return [
1169
- {
1170
- index: 0,
1171
- startTime: 0,
1172
- endTime: totalDuration,
1173
- duration: totalDuration,
1174
- allocatedFrames: Math.min(
1175
- Math.ceil(effectiveFps * totalDuration),
1176
- maxFrames
1177
- ),
1178
- effectiveFps,
1179
- overlapBefore: 0,
1180
- overlapAfter: 0,
1181
- extractStartTime: 0,
1182
- extractDuration: totalDuration
1183
- }
1184
- ];
1185
- }
1186
- const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1187
- const overlapTime = 1 / effectiveFps;
1188
- const segments = [];
1189
- for (let i = 0; i < segmentCount; i++) {
1190
- const startTime = i * maxSegmentDuration;
1191
- const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1192
- const duration = endTime - startTime;
1193
- const overlapBefore = i > 0 ? 1 : 0;
1194
- const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1195
- const extractStartTime = Math.max(
1196
- 0,
1197
- startTime - overlapBefore * overlapTime
1198
- );
1199
- const extractEndTime = Math.min(
1200
- totalDuration,
1201
- endTime + overlapAfter * overlapTime
1202
- );
1203
- const extractDuration = extractEndTime - extractStartTime;
1204
- segments.push({
1205
- index: i,
1206
- startTime,
1207
- endTime,
1208
- duration,
1209
- allocatedFrames: Math.ceil(effectiveFps * duration),
1210
- effectiveFps,
1211
- overlapBefore,
1212
- overlapAfter,
1213
- extractStartTime,
1214
- extractDuration
1215
- });
1216
- }
1217
- const totalAllocated = segments.reduce(
1218
- (sum, s) => sum + s.allocatedFrames,
1219
- 0
1220
- );
1221
- if (totalAllocated > maxFrames) {
1222
- segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1223
- }
1224
- return segments;
1545
+ const frameLimit = Math.max(2, maxFrames);
1546
+ const effectiveFps = Math.min(fps, frameLimit / totalDuration);
1547
+ if (totalDuration <= maxSegmentDuration) return [{
1548
+ index: 0,
1549
+ startTime: 0,
1550
+ endTime: totalDuration,
1551
+ duration: totalDuration,
1552
+ allocatedFrames: frameLimit,
1553
+ effectiveFps,
1554
+ overlapBefore: 0,
1555
+ overlapAfter: 0,
1556
+ extractStartTime: 0,
1557
+ extractDuration: totalDuration
1558
+ }];
1559
+ const segments = [];
1560
+ for (let slot = 0; slot < frameLimit; slot++) {
1561
+ const timestamp = slot / effectiveFps;
1562
+ if (timestamp >= totalDuration) break;
1563
+ const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
1564
+ const previous = segments[segments.length - 1];
1565
+ if (previous?.startTime === startTime) {
1566
+ previous.allocatedFrames++;
1567
+ continue;
1568
+ }
1569
+ const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
1570
+ segments.push({
1571
+ index: segments.length,
1572
+ startTime,
1573
+ endTime,
1574
+ duration: endTime - startTime,
1575
+ allocatedFrames: 1,
1576
+ effectiveFps,
1577
+ overlapBefore: 0,
1578
+ overlapAfter: 0,
1579
+ extractStartTime: timestamp,
1580
+ extractDuration: 0
1581
+ });
1582
+ }
1583
+ let firstSlot = 0;
1584
+ for (const segment of segments) {
1585
+ const nextSlot = firstSlot + segment.allocatedFrames;
1586
+ segment.overlapBefore = segment.index > 0 ? 1 : 0;
1587
+ segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
1588
+ segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
1589
+ segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
1590
+ segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
1591
+ firstSlot = nextSlot;
1592
+ }
1593
+ return segments;
1225
1594
  }
1595
+ /**
1596
+ * Collect all frames from every segment, adjusting timestamps by extractStartTime.
1597
+ */
1226
1598
  function collectAllFrames(segmentResults) {
1227
- const allFrames = [];
1228
- for (const result of segmentResults) {
1229
- for (const frame of result.frames) {
1230
- allFrames.push({
1231
- frame: {
1232
- ...frame,
1233
- // Use extractStartTime for timestamp correction (Section 18 note 1)
1234
- timestamp: frame.timestamp + result.segment.extractStartTime
1235
- },
1236
- segmentIndex: result.segment.index,
1237
- localId: frame.id
1238
- });
1239
- }
1240
- }
1241
- return allFrames;
1599
+ const allFrames = [];
1600
+ for (const result of segmentResults) for (const frame of result.frames) allFrames.push({
1601
+ frame: {
1602
+ ...frame,
1603
+ timestamp: frame.timestamp + result.segment.extractStartTime
1604
+ },
1605
+ segmentIndex: result.segment.index,
1606
+ localId: frame.id
1607
+ });
1608
+ return allFrames;
1242
1609
  }
1610
+ /**
1611
+ * Sort frames in place and alias overlap duplicates to the first survivor.
1612
+ * @param frames Collected entries whose segment index and local ID identify a frame.
1613
+ * @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
1614
+ * @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
1615
+ */
1243
1616
  function deduplicateFrames(frames, effectiveFps) {
1244
- frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1245
- const dupThreshold = 1 / (effectiveFps * 2);
1246
- const unique = [];
1247
- for (const entry of frames) {
1248
- if (unique.length > 0) {
1249
- const last = unique[unique.length - 1];
1250
- if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1251
- continue;
1252
- }
1253
- }
1254
- unique.push(entry);
1255
- }
1256
- return unique;
1617
+ frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1618
+ const dupThreshold = 1 / (effectiveFps * 2);
1619
+ const unique = [];
1620
+ const aliases = /* @__PURE__ */ new Map();
1621
+ for (const entry of frames) {
1622
+ if (unique.length > 0) {
1623
+ const last = unique[unique.length - 1];
1624
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1625
+ aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
1626
+ continue;
1627
+ }
1628
+ }
1629
+ unique.push(entry);
1630
+ }
1631
+ return {
1632
+ unique,
1633
+ aliases
1634
+ };
1257
1635
  }
1258
- function remapFrameIds(uniqueFrames) {
1259
- const globalIdMap = /* @__PURE__ */ new Map();
1260
- const frames = uniqueFrames.map((entry, globalId) => {
1261
- globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1262
- return {
1263
- id: globalId,
1264
- timestamp: entry.frame.timestamp,
1265
- extractPath: entry.frame.extractPath
1266
- };
1267
- });
1268
- return { frames, globalIdMap };
1636
+ /**
1637
+ * Assign sequential global IDs and retain duplicate local IDs as aliases.
1638
+ * @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
1639
+ * @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
1640
+ * @returns Remapped frames and a global ID lookup covering survivors and duplicates.
1641
+ */
1642
+ function remapFrameIds(uniqueFrames, aliases) {
1643
+ const globalIdMap = /* @__PURE__ */ new Map();
1644
+ const frames = uniqueFrames.map((entry, globalId) => {
1645
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1646
+ return {
1647
+ id: globalId,
1648
+ timestamp: entry.frame.timestamp,
1649
+ extractPath: entry.frame.extractPath
1650
+ };
1651
+ });
1652
+ for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
1653
+ return {
1654
+ frames,
1655
+ globalIdMap
1656
+ };
1269
1657
  }
1658
+ /**
1659
+ * Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
1660
+ * @param segmentResults Segment-local edges in encounter order.
1661
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1662
+ * @returns One edge per surviving directed pair without changing its score.
1663
+ */
1270
1664
  function remapEdges(segmentResults, globalIdMap) {
1271
- const edges = [];
1272
- const edgeMap = /* @__PURE__ */ new Map();
1273
- for (const result of segmentResults) {
1274
- for (const edge of result.edges) {
1275
- const newSourceId = globalIdMap.get(
1276
- `${result.segment.index}:${edge.sourceId}`
1277
- );
1278
- const newTargetId = globalIdMap.get(
1279
- `${result.segment.index}:${edge.targetId}`
1280
- );
1281
- if (newSourceId === void 0 || newTargetId === void 0) continue;
1282
- const edgeKey = `${newSourceId}-${newTargetId}`;
1283
- const existingIdx = edgeMap.get(edgeKey);
1284
- if (existingIdx !== void 0) {
1285
- if (edges[existingIdx].score < edge.score) {
1286
- edges[existingIdx] = {
1287
- sourceId: newSourceId,
1288
- targetId: newTargetId,
1289
- score: edge.score
1290
- };
1291
- }
1292
- } else {
1293
- edgeMap.set(edgeKey, edges.length);
1294
- edges.push({
1295
- sourceId: newSourceId,
1296
- targetId: newTargetId,
1297
- score: edge.score
1298
- });
1299
- }
1300
- }
1301
- }
1302
- return edges;
1665
+ const edges = [];
1666
+ const edgeMap = /* @__PURE__ */ new Map();
1667
+ for (const result of segmentResults) for (const edge of result.edges) {
1668
+ const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
1669
+ const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
1670
+ if (newSourceId === void 0 || newTargetId === void 0) continue;
1671
+ if (newSourceId === newTargetId) continue;
1672
+ const edgeKey = `${newSourceId}-${newTargetId}`;
1673
+ const existingIdx = edgeMap.get(edgeKey);
1674
+ if (existingIdx !== void 0) {
1675
+ if (edges[existingIdx].score < edge.score) edges[existingIdx] = {
1676
+ sourceId: newSourceId,
1677
+ targetId: newTargetId,
1678
+ score: edge.score
1679
+ };
1680
+ } else {
1681
+ edgeMap.set(edgeKey, edges.length);
1682
+ edges.push({
1683
+ sourceId: newSourceId,
1684
+ targetId: newTargetId,
1685
+ score: edge.score
1686
+ });
1687
+ }
1688
+ }
1689
+ return edges;
1303
1690
  }
1691
+ /**
1692
+ * Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
1693
+ * @param segmentResults Segment-local tracker entries in encounter order.
1694
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1695
+ * @returns One animation per directed pair with its original tracker duration and metadata.
1696
+ */
1304
1697
  function remapAnimations(segmentResults, globalIdMap) {
1305
- const animations = [];
1306
- for (const result of segmentResults) {
1307
- for (const anim of result.animations) {
1308
- const newStartId = globalIdMap.get(
1309
- `${result.segment.index}:${anim.startFrameId}`
1310
- );
1311
- const newEndId = globalIdMap.get(
1312
- `${result.segment.index}:${anim.endFrameId}`
1313
- );
1314
- if (newStartId === void 0 || newEndId === void 0) continue;
1315
- animations.push({
1316
- ...anim,
1317
- startFrameId: newStartId,
1318
- endFrameId: newEndId
1319
- });
1320
- }
1321
- }
1322
- return animations;
1698
+ const animations = /* @__PURE__ */ new Map();
1699
+ for (const result of segmentResults) for (const anim of result.animations) {
1700
+ const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
1701
+ const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
1702
+ if (newStartId === void 0 || newEndId === void 0) continue;
1703
+ if (newStartId === newEndId) continue;
1704
+ const animationKey = `${newStartId}-${newEndId}`;
1705
+ if (animations.has(animationKey)) continue;
1706
+ animations.set(animationKey, {
1707
+ ...anim,
1708
+ startFrameId: newStartId,
1709
+ endFrameId: newEndId
1710
+ });
1711
+ }
1712
+ return [...animations.values()];
1323
1713
  }
1714
+ /**
1715
+ * Merge multiple segment results into a single unified frame/edge/animation set.
1716
+ * @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
1717
+ * @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
1718
+ * Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
1719
+ */
1324
1720
  function mergeSegmentFrames(segmentResults) {
1325
- if (segmentResults.length === 0) {
1326
- return { frames: [], edges: [], animations: [] };
1327
- }
1328
- const effectiveFps = segmentResults[0].segment.effectiveFps;
1329
- const allFrames = collectAllFrames(segmentResults);
1330
- const uniqueFrames = deduplicateFrames(allFrames, effectiveFps);
1331
- const { frames, globalIdMap } = remapFrameIds(uniqueFrames);
1332
- const edges = remapEdges(segmentResults, globalIdMap);
1333
- const animations = remapAnimations(segmentResults, globalIdMap);
1334
- return { frames, edges, animations };
1721
+ if (segmentResults.length === 0) return {
1722
+ frames: [],
1723
+ edges: [],
1724
+ animations: [],
1725
+ analysisResolution: {
1726
+ width: 0,
1727
+ height: 0
1728
+ }
1729
+ };
1730
+ const effectiveFps = segmentResults[0].segment.effectiveFps;
1731
+ const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
1732
+ const { frames, globalIdMap } = remapFrameIds(unique, aliases);
1733
+ return {
1734
+ frames,
1735
+ edges: remapEdges(segmentResults, globalIdMap),
1736
+ animations: remapAnimations(segmentResults, globalIdMap),
1737
+ analysisResolution: segmentResults[0].analysisResolution
1738
+ };
1335
1739
  }
1336
1740
  function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
1337
- return {
1338
- options: {
1339
- ...resolvedOptions,
1340
- fps: segment.effectiveFps,
1341
- maxFrames: segment.allocatedFrames
1342
- },
1343
- workspacePath: segmentWorkspacePath,
1344
- frames,
1345
- graph: [],
1346
- status: "ANALYZING",
1347
- emitProgress: onProgress
1348
- };
1741
+ return {
1742
+ options: {
1743
+ ...resolvedOptions,
1744
+ fps: segment.effectiveFps,
1745
+ maxFrames: segment.allocatedFrames
1746
+ },
1747
+ workspacePath: segmentWorkspacePath,
1748
+ effectiveFps: segment.effectiveFps,
1749
+ frames,
1750
+ graph: [],
1751
+ status: "ANALYZING",
1752
+ emitProgress: onProgress
1753
+ };
1349
1754
  }
1755
+ /**
1756
+ * Extract frames for a single segment and analyze them.
1757
+ * Each segment uses an isolated workspace directory.
1758
+ */
1350
1759
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1351
- const framesDir = (0, import_node_path6.join)(workspacePath, "frames");
1352
- const frames = await extractFramesForRange(
1353
- inputPath,
1354
- framesDir,
1355
- segment.effectiveFps,
1356
- resolvedOptions.scale,
1357
- segment.extractStartTime,
1358
- segment.extractDuration
1359
- );
1360
- if (frames.length < 2) {
1361
- return { segment, frames, edges: [], animations: [] };
1362
- }
1363
- const ctx = buildSegmentContext(
1364
- segment,
1365
- frames,
1366
- workspacePath,
1367
- resolvedOptions,
1368
- onProgress
1369
- );
1370
- const { edges, animations } = await analyzeFrames(ctx);
1371
- return { segment, frames, edges, animations };
1760
+ const frames = await extractFramesForRange(inputPath, (0, node_path.join)(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1761
+ if (frames.length < 2) return {
1762
+ segment,
1763
+ frames,
1764
+ edges: [],
1765
+ animations: [],
1766
+ analysisResolution: {
1767
+ width: 0,
1768
+ height: 0
1769
+ }
1770
+ };
1771
+ const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1772
+ return {
1773
+ segment,
1774
+ frames,
1775
+ edges,
1776
+ animations,
1777
+ analysisResolution
1778
+ };
1372
1779
  }
1780
+ /**
1781
+ * Full segmented pipeline: metadata → plan → parallel extract+analyze → merge → prune → finalize.
1782
+ * Called from runPipeline when shouldSegment() returns true.
1783
+ */
1373
1784
  async function runSegmentedPipeline(options, resolvedOptions) {
1374
- const pipelineStart = Date.now();
1375
- const sessionId = (0, import_node_crypto.randomUUID)();
1376
- let mainWorkspace = "";
1377
- try {
1378
- mainWorkspace = await createWorkspace(sessionId);
1379
- logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
1380
- const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
1381
- const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
1382
- if (!inputPath) {
1383
- throw new Error("No input path available for segmented pipeline");
1384
- }
1385
- const metadata = await getVideoMetadata(inputPath);
1386
- const totalDuration = parseFloat(metadata.format?.duration ?? "0");
1387
- if (totalDuration <= 0) {
1388
- throw new Error(`Invalid video duration: ${totalDuration}`);
1389
- }
1390
- logger.debug(`Video duration: ${totalDuration}s`);
1391
- const segments = computeSegmentPlan(
1392
- totalDuration,
1393
- resolvedOptions.maxSegmentDuration,
1394
- resolvedOptions.maxFrames,
1395
- resolvedOptions.fps
1396
- );
1397
- logger.debug(`Segment plan: ${segments.length} segments`);
1398
- const limit = concurrencyLimit(resolvedOptions.concurrency);
1399
- const segmentProgresses = new Array(segments.length).fill(0);
1400
- const weights = (0, import_common_utils6.map)(segments, (s) => s.duration / totalDuration);
1401
- const emitOverallProgress = (phase) => {
1402
- if (!options.onProgress) return;
1403
- const overall = weights.reduce(
1404
- (sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
1405
- 0
1406
- );
1407
- options.onProgress(phase, Math.min(100, overall));
1408
- };
1409
- options.onProgress?.("EXTRACTING", 0);
1410
- const results = await Promise.all(
1411
- (0, import_common_utils6.map)(
1412
- segments,
1413
- (segment) => limit(async () => {
1414
- const segWorkspace = await createSegmentWorkspace(
1415
- mainWorkspace,
1416
- segment.index
1417
- );
1418
- const result = await processSegment(
1419
- inputPath,
1420
- segment,
1421
- segWorkspace,
1422
- resolvedOptions,
1423
- (percent) => {
1424
- segmentProgresses[segment.index] = percent;
1425
- emitOverallProgress("ANALYZING");
1426
- }
1427
- );
1428
- return result;
1429
- })
1430
- )
1431
- );
1432
- options.onProgress?.("ANALYZING", 100);
1433
- const { frames, edges, animations } = mergeSegmentFrames(results);
1434
- logger.debug(
1435
- `Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
1436
- );
1437
- options.onProgress?.("PRUNING", 0);
1438
- const survivingIds = pruneByThresholdWithCap(
1439
- edges,
1440
- frames,
1441
- resolvedOptions.threshold,
1442
- resolvedOptions.count
1443
- );
1444
- const prunedFrames = (0, import_common_utils6.filter)(frames, (f) => survivingIds.has(f.id));
1445
- options.onProgress?.("PRUNING", 100);
1446
- options.onProgress?.("FINALIZING", 0);
1447
- const ctx = {
1448
- options: resolvedOptions,
1449
- workspacePath: mainWorkspace,
1450
- frames,
1451
- graph: edges,
1452
- animations,
1453
- status: "FINALIZING",
1454
- emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1455
- };
1456
- let outputFiles = [];
1457
- let outputBuffers;
1458
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1459
- outputBuffers = await readFramesAsBuffers(
1460
- prunedFrames,
1461
- resolvedOptions.quality
1462
- );
1463
- } else {
1464
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1465
- }
1466
- options.onProgress?.("FINALIZING", 100);
1467
- logger.success(
1468
- `Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
1469
- );
1470
- return {
1471
- success: true,
1472
- originalFramesCount: frames.length,
1473
- prunedFramesCount: prunedFrames.length,
1474
- outputFiles,
1475
- outputBuffers,
1476
- animations,
1477
- video: {
1478
- originalDurationMs: totalDuration * 1e3,
1479
- fps: resolvedOptions.fps,
1480
- resolution: {
1481
- width: resolvedOptions.scale,
1482
- height: Math.round(resolvedOptions.scale * 9 / 16)
1483
- }
1484
- },
1485
- executionTimeMs: Date.now() - pipelineStart
1486
- };
1487
- } catch (error) {
1488
- const err = error instanceof Error ? error : new Error(String(error));
1489
- logger.error(`Segmented pipeline failed: ${err.message}`);
1490
- throw err;
1491
- } finally {
1492
- if (!resolvedOptions.debug) {
1493
- await cleanupWorkspace(mainWorkspace);
1494
- } else {
1495
- logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1496
- }
1497
- }
1785
+ const pipelineStart = Date.now();
1786
+ const sessionId = (0, node_crypto.randomUUID)();
1787
+ let mainWorkspace = "";
1788
+ try {
1789
+ mainWorkspace = await createWorkspace(sessionId);
1790
+ logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
1791
+ const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
1792
+ const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
1793
+ if (!inputPath) throw new Error("No input path available for segmented pipeline");
1794
+ const metadata = await getVideoMetadata(inputPath);
1795
+ const totalDuration = parseFloat(metadata.format?.duration ?? "0");
1796
+ if (totalDuration <= 0) throw new Error(`Invalid video duration: ${totalDuration}`);
1797
+ logger.debug(`Video duration: ${totalDuration}s`);
1798
+ const segments = computeSegmentPlan(totalDuration, resolvedOptions.maxSegmentDuration, resolvedOptions.maxFrames, resolvedOptions.fps);
1799
+ logger.debug(`Segment plan: ${segments.length} segments`);
1800
+ const limit = concurrencyLimit(resolvedOptions.concurrency);
1801
+ const segmentProgresses = new Array(segments.length).fill(0);
1802
+ const weights = (0, _winglet_common_utils.map)(segments, (s) => s.duration / totalDuration);
1803
+ const emitOverallProgress = (phase) => {
1804
+ if (!options.onProgress) return;
1805
+ const overall = weights.reduce((sum, w, i) => sum + w * (segmentProgresses[i] ?? 0), 0);
1806
+ options.onProgress(phase, Math.min(100, overall));
1807
+ };
1808
+ options.onProgress?.("EXTRACTING", 0);
1809
+ const results = await Promise.all((0, _winglet_common_utils.map)(segments, (segment) => limit(async () => {
1810
+ const segWorkspace = await createSegmentWorkspace(mainWorkspace, segment.index);
1811
+ return await processSegment(inputPath, segment, segWorkspace, resolvedOptions, (percent) => {
1812
+ segmentProgresses[segment.index] = percent;
1813
+ emitOverallProgress("ANALYZING");
1814
+ });
1815
+ })));
1816
+ options.onProgress?.("ANALYZING", 100);
1817
+ const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
1818
+ logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
1819
+ options.onProgress?.("PRUNING", 0);
1820
+ const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
1821
+ const prunedFrames = (0, _winglet_common_utils.filter)(frames, (f) => survivingIds.has(f.id));
1822
+ options.onProgress?.("PRUNING", 100);
1823
+ options.onProgress?.("FINALIZING", 0);
1824
+ const ctx = {
1825
+ options: resolvedOptions,
1826
+ effectiveFps: segments[0]?.effectiveFps,
1827
+ sourceDurationSec: totalDuration,
1828
+ analysisResolution,
1829
+ workspacePath: mainWorkspace,
1830
+ frames,
1831
+ graph: edges,
1832
+ animations,
1833
+ status: "FINALIZING",
1834
+ emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1835
+ };
1836
+ let outputFiles = [];
1837
+ let outputBuffers;
1838
+ if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1839
+ else outputFiles = await finalizeOutput(ctx, prunedFrames);
1840
+ options.onProgress?.("FINALIZING", 100);
1841
+ logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1842
+ const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1843
+ return {
1844
+ success: true,
1845
+ originalFramesCount: frames.length,
1846
+ prunedFramesCount: prunedFrames.length,
1847
+ outputFiles,
1848
+ outputBuffers,
1849
+ ...outputMetadata,
1850
+ executionTimeMs: Date.now() - pipelineStart
1851
+ };
1852
+ } catch (error) {
1853
+ const err = error instanceof Error ? error : new Error(String(error));
1854
+ logger.error(`Segmented pipeline failed: ${err.message}`);
1855
+ throw err;
1856
+ } finally {
1857
+ if (!resolvedOptions.debug) await cleanupWorkspace(mainWorkspace);
1858
+ else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1859
+ }
1498
1860
  }
1861
+ var init_segmenter$1 = __esmMin((() => {
1862
+ init_concurrency();
1863
+ init_logger();
1864
+ init_analyzer();
1865
+ init_build_video_metadata();
1866
+ init_extractor();
1867
+ init_input_resolver();
1868
+ init_pruner();
1869
+ init_workspace();
1870
+ }));
1499
1871
 
1500
- // src/core/orchestrator.ts
1872
+ //#endregion
1873
+ //#region src/core/segmenter/index.ts
1874
+ var init_segmenter = __esmMin((() => {
1875
+ init_segmenter$1();
1876
+ }));
1877
+
1878
+ //#endregion
1879
+ //#region src/core/orchestrator/orchestrator.ts
1501
1880
  async function runPipeline(options) {
1502
- const debug = options.debug ?? false;
1503
- if (debug) setDebugMode(true);
1504
- const resolvedOptions = resolveOptions(options);
1505
- if (shouldSegment(resolvedOptions, options)) {
1506
- return runSegmentedPipeline(options, resolvedOptions);
1507
- }
1508
- const startTime = Date.now();
1509
- const sessionId = (0, import_node_crypto2.randomUUID)();
1510
- const ctx = {
1511
- options: resolvedOptions,
1512
- workspacePath: "",
1513
- frames: [],
1514
- graph: [],
1515
- status: "INIT",
1516
- emitProgress: (percent) => {
1517
- if (options.onProgress && ctx.status !== "INIT" && ctx.status !== "SUCCESS" && ctx.status !== "FAILED") {
1518
- options.onProgress(ctx.status, percent);
1519
- }
1520
- }
1521
- };
1522
- try {
1523
- ctx.workspacePath = await createWorkspace(sessionId);
1524
- logger.debug(`Workspace created: ${ctx.workspacePath}`);
1525
- ctx.status = "EXTRACTING";
1526
- const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(
1527
- options,
1528
- ctx.workspacePath
1529
- );
1530
- if (resolvedOptions.mode === "frames") {
1531
- ctx.frames = resolvedFrames;
1532
- } else {
1533
- const extractCtx = {
1534
- ...ctx,
1535
- options: {
1536
- ...resolvedOptions,
1537
- inputPath: resolvedInputPath
1538
- }
1539
- };
1540
- ctx.frames = await extractFrames(extractCtx);
1541
- }
1542
- ctx.emitProgress(100);
1543
- ctx.status = "ANALYZING";
1544
- const { edges, animations } = await analyzeFrames(ctx);
1545
- ctx.graph = edges;
1546
- ctx.animations = animations;
1547
- ctx.status = "PRUNING";
1548
- const survivingIds = pruneByThresholdWithCap(
1549
- ctx.graph,
1550
- ctx.frames,
1551
- resolvedOptions.threshold,
1552
- resolvedOptions.count
1553
- );
1554
- const prunedFrames = (0, import_common_utils7.filter)(ctx.frames, (f) => survivingIds.has(f.id));
1555
- ctx.emitProgress(100);
1556
- ctx.status = "FINALIZING";
1557
- let outputFiles = [];
1558
- let outputBuffers;
1559
- if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1560
- outputBuffers = await readFramesAsBuffers(
1561
- prunedFrames,
1562
- resolvedOptions.quality
1563
- );
1564
- ctx.emitProgress(100);
1565
- } else {
1566
- outputFiles = await finalizeOutput(ctx, prunedFrames);
1567
- ctx.emitProgress(100);
1568
- }
1569
- ctx.status = "SUCCESS";
1570
- logger.success(
1571
- `Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`
1572
- );
1573
- return {
1574
- success: true,
1575
- originalFramesCount: ctx.frames.length,
1576
- prunedFramesCount: prunedFrames.length,
1577
- outputFiles,
1578
- outputBuffers,
1579
- animations: ctx.animations,
1580
- video: {
1581
- originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1582
- fps: ctx.options.fps,
1583
- resolution: {
1584
- width: ctx.options.scale,
1585
- height: Math.round(ctx.options.scale * 9 / 16)
1586
- }
1587
- },
1588
- executionTimeMs: Date.now() - startTime
1589
- };
1590
- } catch (error) {
1591
- ctx.status = "FAILED";
1592
- ctx.error = error instanceof Error ? error : new Error(String(error));
1593
- logger.error(`Pipeline failed: ${ctx.error.message}`);
1594
- throw ctx.error;
1595
- } finally {
1596
- if (!resolvedOptions.debug) {
1597
- await cleanupWorkspace(ctx.workspacePath);
1598
- } else {
1599
- logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
1600
- }
1601
- }
1881
+ setDebugMode(options.debug ?? false);
1882
+ const resolvedOptions = resolveOptions(options);
1883
+ if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
1884
+ const startTime = Date.now();
1885
+ const sessionId = (0, node_crypto.randomUUID)();
1886
+ const ctx = {
1887
+ options: resolvedOptions,
1888
+ workspacePath: "",
1889
+ frames: [],
1890
+ graph: [],
1891
+ status: "INIT",
1892
+ emitProgress: (percent) => {
1893
+ if (options.onProgress && ctx.status !== "INIT" && ctx.status !== "SUCCESS" && ctx.status !== "FAILED") options.onProgress(ctx.status, percent);
1894
+ }
1895
+ };
1896
+ try {
1897
+ ctx.workspacePath = await createWorkspace(sessionId);
1898
+ logger.debug(`Workspace created: ${ctx.workspacePath}`);
1899
+ ctx.status = "EXTRACTING";
1900
+ const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
1901
+ if (resolvedOptions.mode === "frames") {
1902
+ ctx.frames = resolvedFrames;
1903
+ ctx.effectiveFps = 1;
1904
+ } else {
1905
+ const extractCtx = {
1906
+ ...ctx,
1907
+ options: {
1908
+ ...resolvedOptions,
1909
+ inputPath: resolvedInputPath
1910
+ }
1911
+ };
1912
+ ctx.frames = await extractFrames(extractCtx);
1913
+ ctx.effectiveFps = extractCtx.effectiveFps;
1914
+ ctx.sourceDurationSec = extractCtx.sourceDurationSec;
1915
+ }
1916
+ ctx.emitProgress(100);
1917
+ ctx.status = "ANALYZING";
1918
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1919
+ ctx.graph = edges;
1920
+ ctx.animations = animations;
1921
+ ctx.analysisResolution = analysisResolution;
1922
+ ctx.status = "PRUNING";
1923
+ const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
1924
+ const prunedFrames = (0, _winglet_common_utils.filter)(ctx.frames, (f) => survivingIds.has(f.id));
1925
+ ctx.emitProgress(100);
1926
+ ctx.status = "FINALIZING";
1927
+ let outputFiles = [];
1928
+ let outputBuffers;
1929
+ if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1930
+ outputBuffers = await readFramesAsBuffers(prunedFrames, resolvedOptions.quality);
1931
+ ctx.emitProgress(100);
1932
+ } else {
1933
+ outputFiles = await finalizeOutput(ctx, prunedFrames);
1934
+ ctx.emitProgress(100);
1935
+ }
1936
+ ctx.status = "SUCCESS";
1937
+ logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1938
+ const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1939
+ return {
1940
+ success: true,
1941
+ originalFramesCount: ctx.frames.length,
1942
+ prunedFramesCount: prunedFrames.length,
1943
+ outputFiles,
1944
+ outputBuffers,
1945
+ ...metadata,
1946
+ executionTimeMs: Date.now() - startTime
1947
+ };
1948
+ } catch (error) {
1949
+ ctx.status = "FAILED";
1950
+ ctx.error = error instanceof Error ? error : new Error(String(error));
1951
+ logger.error(`Pipeline failed: ${ctx.error.message}`);
1952
+ throw ctx.error;
1953
+ } finally {
1954
+ if (!resolvedOptions.debug) await cleanupWorkspace(ctx.workspacePath);
1955
+ else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
1956
+ }
1602
1957
  }
1603
- // Annotate the CommonJS export names for ESM import in node:
1604
- 0 && (module.exports = {
1605
- extractScenes
1606
- });
1958
+ var init_orchestrator = __esmMin((() => {
1959
+ init_logger();
1960
+ init_analyzer();
1961
+ init_build_video_metadata();
1962
+ init_extractor();
1963
+ init_input_resolver();
1964
+ init_pruner();
1965
+ init_segmenter();
1966
+ init_workspace();
1967
+ }));
1968
+
1969
+ //#endregion
1970
+ //#region src/index.ts
1971
+ init_orchestrator();
1972
+
1973
+ //#endregion
1974
+ exports.extractScenes = runPipeline;