@lumy-pack/scene-sieve 0.0.13 → 0.1.0

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