@lumy-pack/scene-sieve 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +47 -24
  2. package/dist/{errors.d.ts → cli/errors/classify-error.d.ts} +5 -0
  3. package/dist/cli/index.d.ts +3 -0
  4. package/dist/{utils → cli/options}/parse-options.d.ts +6 -1
  5. package/dist/cli.mjs +755 -319
  6. package/dist/constants/pipeline-defaults.d.ts +13 -0
  7. package/dist/core/{analyzer.d.ts → analyzer/analyzer.d.ts} +11 -6
  8. package/dist/core/{dbscan.d.ts → analyzer/clustering/dbscan.d.ts} +1 -1
  9. package/dist/core/analyzer/constants/vision-tuning.d.ts +12 -0
  10. package/dist/core/analyzer/features/feature-diff.d.ts +14 -0
  11. package/dist/core/analyzer/features/frame-features.d.ts +32 -0
  12. package/dist/core/analyzer/index.d.ts +3 -0
  13. package/dist/core/constants/workspace-layout.d.ts +9 -0
  14. package/dist/core/{extractor.d.ts → extractor/extractor.d.ts} +6 -4
  15. package/dist/core/extractor/index.d.ts +1 -0
  16. package/dist/core/index.d.ts +8 -9
  17. package/dist/core/input-resolver/index.d.ts +1 -0
  18. package/dist/core/{input-resolver.d.ts → input-resolver/input-resolver.d.ts} +11 -1
  19. package/dist/core/input-resolver/validation/validate-options.d.ts +8 -0
  20. package/dist/core/orchestrator/index.d.ts +3 -0
  21. package/dist/core/{orchestrator.d.ts → orchestrator/orchestrator.d.ts} +1 -1
  22. package/dist/core/{run-in-worker.d.ts → orchestrator/worker/run-in-worker.d.ts} +5 -1
  23. package/dist/core/pruner/index.d.ts +1 -0
  24. package/dist/core/{pruner.d.ts → pruner/pruner.d.ts} +2 -2
  25. package/dist/{utils/math.d.ts → core/pruner/scoring/normalize-scores.d.ts} +4 -0
  26. package/dist/core/segmenter/index.d.ts +1 -0
  27. package/dist/core/{segmenter.d.ts → segmenter/segmenter.d.ts} +11 -11
  28. package/dist/core/utils/metadata/build-video-metadata.d.ts +14 -0
  29. package/dist/core/workspace/index.d.ts +1 -0
  30. package/dist/core/{workspace.d.ts → workspace/workspace.d.ts} +1 -1
  31. package/dist/index.cjs +910 -434
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.mjs +913 -434
  34. package/dist/pipeline-worker.mjs +635 -278
  35. package/dist/types/index.d.ts +25 -0
  36. package/package.json +1 -1
  37. package/dist/constants.d.ts +0 -32
  38. /package/dist/{commands → cli/commands}/Sieve.d.ts +0 -0
  39. /package/dist/{utils → cli/commands}/command-registry.d.ts +0 -0
  40. /package/dist/{components → cli/components}/PhaseStep.d.ts +0 -0
  41. /package/dist/{components → cli/components}/ProgressBar.d.ts +0 -0
  42. /package/dist/core/{pipeline-worker.d.ts → orchestrator/worker/pipeline-worker.d.ts} +0 -0
  43. /package/dist/{utils → core/pruner/heap}/min-heap.d.ts +0 -0
  44. /package/dist/{utils → core/segmenter/scheduling}/concurrency.d.ts +0 -0
  45. /package/dist/{utils → core/utils/filesystem}/paths.d.ts +0 -0
  46. /package/dist/{utils → logging}/logger.d.ts +0 -0
package/dist/index.mjs CHANGED
@@ -3,65 +3,76 @@ import { randomUUID } from "node:crypto";
3
3
  import { filter, map } from "@winglet/common-utils";
4
4
  import pc from "picocolors";
5
5
  import sharp from "sharp";
6
- import { homedir, tmpdir } from "node:os";
7
- import { basename, extname, join, resolve } from "node:path";
8
6
  import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { basename, extname, join, resolve } from "node:path";
9
8
  import { path } from "@ffprobe-installer/ffprobe";
10
9
  import { execa } from "execa";
11
10
  import ffmpegPath from "ffmpeg-static";
11
+ import { homedir, tmpdir } from "node:os";
12
+
13
+ //#region \0rolldown/runtime.js
14
+ var __esmMin = (fn, res, err) => () => {
15
+ if (err) throw err[0];
16
+ try {
17
+ return fn && (res = fn(fn = 0)), res;
18
+ } catch (e) {
19
+ throw err = [e], e;
20
+ }
21
+ };
12
22
 
13
- //#region src/utils/logger.ts
14
- let debugMode = false;
15
- let jsonMode = false;
23
+ //#endregion
24
+ //#region src/logging/logger.ts
16
25
  function setDebugMode(enabled) {
17
26
  debugMode = enabled;
18
27
  }
19
28
  function timestamp() {
20
29
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
21
30
  }
22
- const logger = {
23
- info(message) {
24
- if (jsonMode) process.stderr.write(`${pc.blue("info")} ${message}\n`);
25
- else console.log(`${pc.blue("info")} ${message}`);
26
- },
27
- success(message) {
28
- if (jsonMode) process.stderr.write(`${pc.green("done")} ${message}\n`);
29
- else console.log(`\n${pc.green("done")} ${message}`);
30
- },
31
- warn(message) {
32
- console.warn(`${pc.yellow("warn")} ${message}`);
33
- },
34
- error(message) {
35
- console.error(`${pc.red("error")} ${message}`);
36
- },
37
- debug(message) {
38
- if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
39
- else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
40
- }
41
- };
31
+ var debugMode, jsonMode, logger;
32
+ var init_logger = __esmMin((() => {
33
+ debugMode = false;
34
+ jsonMode = false;
35
+ logger = {
36
+ info(message) {
37
+ if (jsonMode) process.stderr.write(`${pc.blue("info")} ${message}\n`);
38
+ else console.log(`${pc.blue("info")} ${message}`);
39
+ },
40
+ success(message) {
41
+ if (jsonMode) process.stderr.write(`${pc.green("done")} ${message}\n`);
42
+ else console.log(`\n${pc.green("done")} ${message}`);
43
+ },
44
+ warn(message) {
45
+ console.warn(`${pc.yellow("warn")} ${message}`);
46
+ },
47
+ error(message) {
48
+ console.error(`${pc.red("error")} ${message}`);
49
+ },
50
+ debug(message) {
51
+ if (debugMode) if (jsonMode) process.stderr.write(`${pc.gray(`[${timestamp()}] debug`)} ${message}\n`);
52
+ else console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
53
+ }
54
+ };
55
+ }));
42
56
 
43
57
  //#endregion
44
- //#region src/constants.ts
45
- const APP_NAME = "scene-sieve";
46
- const DEFAULT_THRESHOLD = .5;
47
- const NORMALIZATION_ALPHA = .4;
48
- const NORMALIZATION_MAD_COEFFICIENT = 1.4826;
49
- const WORKSPACE_PREFIX = `${APP_NAME}-`;
50
- const TEMP_BASE_DIR = tmpdir();
51
- const FRAME_OUTPUT_EXTENSION = ".jpg";
52
- const FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
53
- const DBSCAN_ALPHA = .03;
54
- const IOU_THRESHOLD = .9;
55
- const DECAY_LAMBDA = .95;
56
- const MATCH_DISTANCE_THRESHOLD = .25;
57
- function getTempWorkspaceDir(sessionId) {
58
- return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
59
- }
58
+ //#region src/constants/pipeline-defaults.ts
59
+ var DEFAULT_THRESHOLD, IOU_THRESHOLD;
60
+ var init_pipeline_defaults = __esmMin((() => {
61
+ DEFAULT_THRESHOLD = .5;
62
+ IOU_THRESHOLD = .9;
63
+ }));
64
+
65
+ //#endregion
66
+ //#region src/core/analyzer/constants/vision-tuning.ts
67
+ var DBSCAN_ALPHA, DECAY_LAMBDA, MATCH_DISTANCE_THRESHOLD;
68
+ var init_vision_tuning = __esmMin((() => {
69
+ DBSCAN_ALPHA = .03;
70
+ DECAY_LAMBDA = .95;
71
+ MATCH_DISTANCE_THRESHOLD = .25;
72
+ }));
60
73
 
61
74
  //#endregion
62
- //#region src/core/dbscan.ts
63
- const UNVISITED = -2;
64
- const NOISE = -1;
75
+ //#region src/core/analyzer/clustering/dbscan.ts
65
76
  /**
66
77
  * DBSCAN clustering with resolution-independent eps.
67
78
  * eps = alpha * sqrt(width^2 + height^2)
@@ -137,12 +148,118 @@ function findNeighbors(points, idx, epsSquared) {
137
148
  }
138
149
  return neighbors;
139
150
  }
151
+ var UNVISITED, NOISE;
152
+ var init_dbscan = __esmMin((() => {
153
+ init_vision_tuning();
154
+ UNVISITED = -2;
155
+ NOISE = -1;
156
+ }));
140
157
 
141
158
  //#endregion
142
- //#region src/core/analyzer.ts
143
- const OPENCV_INIT_TIMEOUT_MS = 3e4;
144
- const require = createRequire(import.meta.url);
145
- let cvReady = null;
159
+ //#region src/core/analyzer/features/feature-diff.ts
160
+ /**
161
+ * Match prev to next with Hamming k=2, crossCheck=false and strict ratio 0.25.
162
+ * @param cvLib - Initialized OpenCV runtime.
163
+ * @param prev - Previous frame's live features, owned by the caller.
164
+ * @param next - Next frame's live features, owned by the caller.
165
+ * @returns Unmatched next-frame coordinates without changing input ownership.
166
+ * @throws Propagates matching errors after releasing temporary native handles.
167
+ */
168
+ function computeNewPoints(cvLib, prev, next) {
169
+ let matcher = null;
170
+ let matches = null;
171
+ try {
172
+ const matchedIndices = /* @__PURE__ */ new Set();
173
+ if (prev.descriptors.rows > 0 && next.descriptors.rows > 0) try {
174
+ matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
175
+ matches = new cvLib.DMatchVectorVector();
176
+ matcher.knnMatch(prev.descriptors, next.descriptors, matches, 2);
177
+ for (let i = 0; i < matches.size(); i++) {
178
+ const pair = matches.get(i);
179
+ try {
180
+ if (pair.size() < 2) continue;
181
+ const best = pair.get(0);
182
+ const second = pair.get(1);
183
+ if (best.distance < .25 * second.distance) matchedIndices.add(best.trainIdx);
184
+ } finally {
185
+ pair.delete();
186
+ }
187
+ }
188
+ } finally {
189
+ matcher?.delete();
190
+ }
191
+ const points = [];
192
+ for (let i = 0; i < next.keypoints.size(); i++) if (!matchedIndices.has(i)) {
193
+ const { x, y } = next.keypoints.get(i).pt;
194
+ points.push({
195
+ x,
196
+ y
197
+ });
198
+ }
199
+ return points;
200
+ } finally {
201
+ matches?.delete();
202
+ }
203
+ }
204
+ var init_feature_diff = __esmMin((() => {
205
+ init_vision_tuning();
206
+ }));
207
+
208
+ //#endregion
209
+ //#region src/core/analyzer/features/frame-features.ts
210
+ /**
211
+ * Detect one frame's features without retaining its image or mask.
212
+ * @param cvLib - Initialized OpenCV runtime.
213
+ * @param akaze - Detector owned and released by the caller.
214
+ * @param frame - Grayscale bytes with matching width and height.
215
+ * @returns Feature handles that the caller must delete.
216
+ * @throws Propagates native errors after releasing partial allocations.
217
+ */
218
+ function computeFrameFeatures(cvLib, akaze, frame) {
219
+ let image = null;
220
+ let mask = null;
221
+ let keypoints = null;
222
+ let descriptors = null;
223
+ try {
224
+ image = new cvLib.Mat(frame.height, frame.width, cvLib.CV_8UC1);
225
+ image.data.set(frame.data);
226
+ mask = new cvLib.Mat();
227
+ keypoints = new cvLib.KeyPointVector();
228
+ descriptors = new cvLib.Mat();
229
+ akaze.detectAndCompute(image, mask, keypoints, descriptors);
230
+ const ownedKeypoints = keypoints;
231
+ const ownedDescriptors = descriptors;
232
+ let deleted = false;
233
+ const features = {
234
+ width: frame.width,
235
+ height: frame.height,
236
+ keypoints: ownedKeypoints,
237
+ descriptors: ownedDescriptors,
238
+ /** Release the transferred handles exactly once. */
239
+ delete() {
240
+ if (deleted) return;
241
+ deleted = true;
242
+ try {
243
+ ownedKeypoints.delete();
244
+ } finally {
245
+ ownedDescriptors.delete();
246
+ }
247
+ }
248
+ };
249
+ keypoints = null;
250
+ descriptors = null;
251
+ return features;
252
+ } finally {
253
+ image?.delete();
254
+ mask?.delete();
255
+ keypoints?.delete();
256
+ descriptors?.delete();
257
+ }
258
+ }
259
+ var init_frame_features = __esmMin((() => {}));
260
+
261
+ //#endregion
262
+ //#region src/core/analyzer/analyzer.ts
146
263
  async function ensureOpenCV() {
147
264
  if (!cvReady) cvReady = (async () => {
148
265
  const cvObj = require("@techstark/opencv-js");
@@ -181,158 +298,6 @@ function computeIoU(a, b) {
181
298
  const union = a.width * a.height + b.width * b.height - intersection;
182
299
  return union === 0 ? 0 : intersection / union;
183
300
  }
184
- var IoUTracker = class {
185
- fps;
186
- iouThreshold;
187
- animationThreshold;
188
- regions = [];
189
- extractedAnimations = [];
190
- constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
191
- this.fps = fps;
192
- this.iouThreshold = iouThreshold;
193
- this.animationThreshold = animationThreshold;
194
- }
195
- update(boxes, pairIndex) {
196
- const animationIndices = /* @__PURE__ */ new Set();
197
- const matched = /* @__PURE__ */ new Set();
198
- for (let bi = 0; bi < boxes.length; bi++) {
199
- const box = boxes[bi];
200
- let bestIoU = 0;
201
- let bestRegionIdx = -1;
202
- for (let ri = 0; ri < this.regions.length; ri++) {
203
- if (matched.has(ri)) continue;
204
- const iou = computeIoU(box, this.regions[ri].box);
205
- if (iou > bestIoU) {
206
- bestIoU = iou;
207
- bestRegionIdx = ri;
208
- }
209
- }
210
- if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
211
- const region = this.regions[bestRegionIdx];
212
- const gap = pairIndex - region.lastSeen;
213
- region.box = box;
214
- region.consecutiveCount++;
215
- region.lastSeen = pairIndex;
216
- region.weight *= Math.pow(DECAY_LAMBDA, gap);
217
- matched.add(bestRegionIdx);
218
- if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
219
- } else this.regions.push({
220
- box,
221
- consecutiveCount: 1,
222
- firstSeen: pairIndex,
223
- lastSeen: pairIndex,
224
- weight: 1
225
- });
226
- }
227
- for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
228
- const gap = pairIndex - this.regions[ri].lastSeen;
229
- this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
230
- }
231
- for (let i = 0; i < this.regions.length; i++) {
232
- const region = this.regions[i];
233
- if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
234
- }
235
- this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
236
- return animationIndices;
237
- }
238
- collectAnimation(region) {
239
- if (region.consecutiveCount >= this.animationThreshold) {
240
- const durationMs = region.consecutiveCount / this.fps * 1e3;
241
- this.extractedAnimations.push({
242
- type: "loading_spinner",
243
- boundingBox: region.box,
244
- startFrameId: region.firstSeen,
245
- endFrameId: region.lastSeen,
246
- durationMs
247
- });
248
- }
249
- }
250
- flushAndGetAnimations() {
251
- for (const region of this.regions) this.collectAnimation(region);
252
- this.regions = [];
253
- return this.extractedAnimations;
254
- }
255
- getAnimationWeight(boxIndex, boxes) {
256
- if (boxIndex >= boxes.length) return 0;
257
- const box = boxes[boxIndex];
258
- let maxWeight = 0;
259
- for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
260
- if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
261
- }
262
- return maxWeight;
263
- }
264
- };
265
- async function computeAKAZEDiff(cvLib, frame1, frame2) {
266
- const cv = cvLib;
267
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
268
- mat1.data.set(frame1.data);
269
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
270
- mat2.data.set(frame2.data);
271
- const kp1 = new cvLib.KeyPointVector();
272
- const kp2 = new cvLib.KeyPointVector();
273
- const desc1 = new cvLib.Mat();
274
- const desc2 = new cvLib.Mat();
275
- const mask1 = new cvLib.Mat();
276
- const mask2 = new cvLib.Mat();
277
- const akaze = new cvLib.AKAZE();
278
- let matches = null;
279
- try {
280
- akaze.detectAndCompute(mat1, mask1, kp1, desc1);
281
- akaze.detectAndCompute(mat2, mask2, kp2, desc2);
282
- const matchedKp1Indices = /* @__PURE__ */ new Set();
283
- const matchedKp2Indices = /* @__PURE__ */ new Set();
284
- if (desc1.rows > 0 && desc2.rows > 0) {
285
- const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
286
- try {
287
- matches = new cvLib.DMatchVectorVector();
288
- matcher.knnMatch(desc1, desc2, matches, 2);
289
- for (let i = 0; i < matches.size(); i++) {
290
- const pair = matches.get(i);
291
- if (pair.size() < 2) continue;
292
- const m0 = pair.get(0);
293
- const m1 = pair.get(1);
294
- if (m0.distance < .25 * m1.distance) {
295
- matchedKp1Indices.add(m0.queryIdx);
296
- matchedKp2Indices.add(m0.trainIdx);
297
- }
298
- }
299
- } finally {
300
- matcher.delete();
301
- }
302
- }
303
- const sNew = [];
304
- for (let i = 0; i < kp2.size(); i++) if (!matchedKp2Indices.has(i)) {
305
- const pt = kp2.get(i).pt;
306
- sNew.push({
307
- x: pt.x,
308
- y: pt.y
309
- });
310
- }
311
- const sLoss = [];
312
- for (let i = 0; i < kp1.size(); i++) if (!matchedKp1Indices.has(i)) {
313
- const pt = kp1.get(i).pt;
314
- sLoss.push({
315
- x: pt.x,
316
- y: pt.y
317
- });
318
- }
319
- return {
320
- sNew,
321
- sLoss
322
- };
323
- } finally {
324
- mat1.delete();
325
- mat2.delete();
326
- kp1.delete();
327
- kp2.delete();
328
- desc1.delete();
329
- desc2.delete();
330
- mask1.delete();
331
- mask2.delete();
332
- akaze.delete();
333
- if (matches) matches.delete();
334
- }
335
- }
336
301
  /**
337
302
  * Pixel-level difference fallback for AKAZE blind spots.
338
303
  *
@@ -347,17 +312,30 @@ async function computeAKAZEDiff(cvLib, frame1, frame2) {
347
312
  * 3. threshold → binary mask of significant changes
348
313
  * 4. findContours → bounding rects of changed regions
349
314
  * 5. Grid sampling within each bounding rect → Point2D[]
315
+ *
316
+ * @param cvLib - Initialized OpenCV runtime shared by the analyzer.
317
+ * @param frame1 - Previous grayscale frame, with the same dimensions as frame2.
318
+ * @param frame2 - Next grayscale frame, with the same dimensions as frame1.
319
+ * @returns Grid-sampled points from changed regions.
320
+ * @throws Propagates allocation or OpenCV errors after releasing acquired handles.
350
321
  */
351
322
  function computePixelDiff(cvLib, frame1, frame2) {
352
323
  const cv = cvLib;
353
- const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
354
- const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
355
- const diff = new cv.Mat();
356
- const blurred = new cv.Mat();
357
- const binary = new cv.Mat();
358
- const contours = new cv.MatVector();
359
- const hierarchy = new cv.Mat();
324
+ let mat1 = null;
325
+ let mat2 = null;
326
+ let diff = null;
327
+ let blurred = null;
328
+ let binary = null;
329
+ let contours = null;
330
+ let hierarchy = null;
360
331
  try {
332
+ mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
333
+ mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
334
+ diff = new cv.Mat();
335
+ blurred = new cv.Mat();
336
+ binary = new cv.Mat();
337
+ contours = new cv.MatVector();
338
+ hierarchy = new cv.Mat();
361
339
  mat1.data.set(frame1.data);
362
340
  mat2.data.set(frame2.data);
363
341
  cv.absdiff(mat1, mat2, diff);
@@ -368,22 +346,26 @@ function computePixelDiff(cvLib, frame1, frame2) {
368
346
  const points = [];
369
347
  for (let c = 0; c < contours.size(); c++) {
370
348
  const contour = contours.get(c);
371
- const rect = cv.boundingRect(contour);
372
- if (rect.width * rect.height < 100) continue;
373
- 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({
374
- x,
375
- y
376
- });
349
+ try {
350
+ const rect = cv.boundingRect(contour);
351
+ if (rect.width * rect.height < 100) continue;
352
+ 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({
353
+ x,
354
+ y
355
+ });
356
+ } finally {
357
+ contour.delete();
358
+ }
377
359
  }
378
360
  return points;
379
361
  } finally {
380
- mat1.delete();
381
- mat2.delete();
382
- diff.delete();
383
- blurred.delete();
384
- binary.delete();
385
- contours.delete();
386
- hierarchy.delete();
362
+ mat1?.delete();
363
+ mat2?.delete();
364
+ diff?.delete();
365
+ blurred?.delete();
366
+ binary?.delete();
367
+ contours?.delete();
368
+ hierarchy?.delete();
387
369
  }
388
370
  }
389
371
  function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
@@ -402,47 +384,85 @@ function computeInformationGain(clusters, clusterPoints, imageArea, animationInd
402
384
  }
403
385
  return gain;
404
386
  }
405
- async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
387
+ /**
388
+ * Analyze one batch, retaining its boundary frame for the following batch.
389
+ * @param cvLib - Initialized OpenCV runtime.
390
+ * @param akaze - Detector owned by analyzeFrames.
391
+ * @param frames - Boundary frame followed by new adjacent frames.
392
+ * @param carry - Boundary bytes and live features transferred from the previous batch.
393
+ * @param scale - Maximum preprocessing width.
394
+ * @param tracker - Stateful animation tracker shared across batches.
395
+ * @param pairOffset - Global position of this batch's first pair.
396
+ * @returns Scores, pair failure count, and ownership of the final frame's features.
397
+ */
398
+ async function analyzeBatch(cvLib, akaze, frames, carry, scale, tracker, pairOffset) {
406
399
  const edges = [];
407
- const preprocessed = await Promise.all(map(frames, (f) => preprocessFrame(f.extractPath, scale)));
408
- const imageWidth = preprocessed[0]?.width ?? scale;
409
- const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
410
- const imageArea = imageWidth * imageHeight;
411
- for (let i = 0; i < frames.length - 1; i++) {
412
- const pairIndex = pairOffset + i;
413
- try {
414
- const { sNew } = await computeAKAZEDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
415
- let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
416
- let clusters = dbscanResult.boundingBoxes;
417
- if (clusters.length === 0) {
418
- const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
419
- if (pixelDiffPoints.length > 0) {
420
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
421
- dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
422
- clusters = dbscanResult.boundingBoxes;
400
+ let failures = 0;
401
+ let prev = carry?.features ?? null;
402
+ let next = null;
403
+ try {
404
+ const preprocessed = await Promise.all(map(frames, (f, index) => index === 0 && carry ? carry.preprocessed : preprocessFrame(f.extractPath, scale)));
405
+ const imageWidth = preprocessed[0]?.width ?? scale;
406
+ const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
407
+ const imageArea = imageWidth * imageHeight;
408
+ for (let i = 0; i < frames.length - 1; i++) {
409
+ const pairIndex = pairOffset + i;
410
+ try {
411
+ prev ??= computeFrameFeatures(cvLib, akaze, preprocessed[i]);
412
+ next = computeFrameFeatures(cvLib, akaze, preprocessed[i + 1]);
413
+ let dbscanResult = dbscan(computeNewPoints(cvLib, prev, next), imageWidth, imageHeight);
414
+ let clusters = dbscanResult.boundingBoxes;
415
+ if (clusters.length === 0) {
416
+ const pixelDiffPoints = computePixelDiff(cvLib, preprocessed[i], preprocessed[i + 1]);
417
+ if (pixelDiffPoints.length > 0) {
418
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`);
419
+ dbscanResult = dbscan(pixelDiffPoints, imageWidth, imageHeight, void 0, 2);
420
+ clusters = dbscanResult.boundingBoxes;
421
+ }
423
422
  }
423
+ const clusterPointCounts = new Array(clusters.length).fill(0);
424
+ for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
425
+ const animationIndices = tracker.update(clusters, pairIndex);
426
+ const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
427
+ const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
428
+ logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
429
+ edges.push({
430
+ sourceId: frames[i].id,
431
+ targetId: frames[i + 1].id,
432
+ score
433
+ });
434
+ } catch (err) {
435
+ logger.warn(`Frame pair analysis failed: ${String(err)}`);
436
+ failures++;
437
+ edges.push({
438
+ sourceId: frames[i].id,
439
+ targetId: frames[i + 1].id,
440
+ score: 0
441
+ });
442
+ } finally {
443
+ prev?.delete();
444
+ prev = next;
445
+ next = null;
424
446
  }
425
- const clusterPointCounts = new Array(clusters.length).fill(0);
426
- for (const label of dbscanResult.labels) if (label >= 0) clusterPointCounts[label]++;
427
- const animationIndices = tracker.update(clusters, pairIndex);
428
- const animationWeights = map(clusters, (_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0);
429
- const score = computeInformationGain(clusters, clusterPointCounts, imageArea, animationIndices, animationWeights);
430
- logger.debug(`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`);
431
- edges.push({
432
- sourceId: frames[i].id,
433
- targetId: frames[i + 1].id,
434
- score
435
- });
436
- } catch (err) {
437
- logger.debug(`Frame pair analysis failed: ${String(err)}`);
438
- edges.push({
439
- sourceId: frames[i].id,
440
- targetId: frames[i + 1].id,
441
- score: 0
442
- });
443
447
  }
448
+ const result = {
449
+ edges,
450
+ failures,
451
+ analysisResolution: {
452
+ width: imageWidth,
453
+ height: imageHeight
454
+ },
455
+ carry: prev ? {
456
+ preprocessed: preprocessed[preprocessed.length - 1],
457
+ features: prev
458
+ } : null
459
+ };
460
+ prev = null;
461
+ return result;
462
+ } finally {
463
+ prev?.delete();
464
+ next?.delete();
444
465
  }
445
- return edges;
446
466
  }
447
467
  /**
448
468
  * Analyze adjacent frame pairs to compute information gain scores (G(t)).
@@ -453,35 +473,222 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
453
473
  * 2. DBSCAN Spatial Clustering
454
474
  * 3. Spatio-temporal IoU Tracking
455
475
  * 4. G(t) Information Gain Scoring
476
+ * @param ctx - Frames, analysis options, and the progress callback for this run.
477
+ * @returns Adjacent scores and tracked animations in analysis coordinates.
478
+ * @throws Propagates runtime errors and rejects total failure of two or more pairs after cleanup.
456
479
  */
457
480
  async function analyzeFrames(ctx) {
458
481
  const { frames } = ctx;
459
482
  if (frames.length < 2) return {
460
483
  edges: [],
461
- animations: []
484
+ animations: [],
485
+ analysisResolution: {
486
+ width: 0,
487
+ height: 0
488
+ }
462
489
  };
463
490
  logger.debug(`Analyzing ${frames.length} frames in batches of ${10}`);
464
491
  const cvLib = await ensureOpenCV();
465
492
  const edges = [];
466
- const tracker = new IoUTracker(ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
493
+ const tracker = new IoUTracker(ctx.effectiveFps ?? ctx.options.fps, ctx.options.iouThreshold, ctx.options.animationThreshold);
467
494
  const scale = ctx.options.scale;
468
- for (let i = 0; i < frames.length - 1; i += 10) {
469
- const batchEnd = Math.min(i + 10 + 1, frames.length);
470
- const batchEdges = await analyzeBatch(cvLib, frames.slice(i, batchEnd), scale, tracker, i);
471
- edges.push(...batchEdges);
472
- const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
473
- ctx.emitProgress(progress);
495
+ let akaze = null;
496
+ let carry = null;
497
+ let analysisResolution = {
498
+ width: 0,
499
+ height: 0
500
+ };
501
+ let failures = 0;
502
+ try {
503
+ akaze = new cvLib.AKAZE();
504
+ for (let i = 0; i < frames.length - 1; i += 10) {
505
+ const batch = [frames[i], ...frames.slice(i + 1, i + 1 + 10)];
506
+ const result = await analyzeBatch(cvLib, akaze, batch, carry, scale, tracker, i);
507
+ carry = result.carry;
508
+ failures += result.failures;
509
+ if (i === 0) analysisResolution = result.analysisResolution;
510
+ edges.push(...result.edges);
511
+ const progress = Math.min(100, (i + 10) / (frames.length - 1) * 100);
512
+ ctx.emitProgress(progress);
513
+ }
514
+ } finally {
515
+ carry?.features.delete();
516
+ akaze?.delete();
474
517
  }
518
+ const pairs = frames.length - 1;
519
+ if (pairs >= 2 && failures === pairs) throw new Error(`All ${pairs} frame pairs failed analysis`);
475
520
  const animations = tracker.flushAndGetAnimations();
476
521
  logger.debug(`Computed ${edges.length} score edges and ${animations.length} animations`);
477
522
  return {
478
523
  edges,
479
- animations
524
+ animations,
525
+ analysisResolution
526
+ };
527
+ }
528
+ var OPENCV_INIT_TIMEOUT_MS, require, cvReady, IoUTracker;
529
+ var init_analyzer$1 = __esmMin((() => {
530
+ init_pipeline_defaults();
531
+ init_vision_tuning();
532
+ init_logger();
533
+ init_dbscan();
534
+ init_feature_diff();
535
+ init_frame_features();
536
+ OPENCV_INIT_TIMEOUT_MS = 3e4;
537
+ require = createRequire(import.meta.url);
538
+ cvReady = null;
539
+ IoUTracker = class {
540
+ fps;
541
+ iouThreshold;
542
+ animationThreshold;
543
+ regions = [];
544
+ extractedAnimations = [];
545
+ constructor(fps = 5, iouThreshold = IOU_THRESHOLD, animationThreshold = 5) {
546
+ this.fps = fps;
547
+ this.iouThreshold = iouThreshold;
548
+ this.animationThreshold = animationThreshold;
549
+ }
550
+ update(boxes, pairIndex) {
551
+ const animationIndices = /* @__PURE__ */ new Set();
552
+ const matched = /* @__PURE__ */ new Set();
553
+ for (let bi = 0; bi < boxes.length; bi++) {
554
+ const box = boxes[bi];
555
+ let bestIoU = 0;
556
+ let bestRegionIdx = -1;
557
+ for (let ri = 0; ri < this.regions.length; ri++) {
558
+ if (matched.has(ri)) continue;
559
+ const iou = computeIoU(box, this.regions[ri].box);
560
+ if (iou > bestIoU) {
561
+ bestIoU = iou;
562
+ bestRegionIdx = ri;
563
+ }
564
+ }
565
+ if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
566
+ const region = this.regions[bestRegionIdx];
567
+ const gap = pairIndex - region.lastSeen;
568
+ region.box = box;
569
+ region.consecutiveCount++;
570
+ region.lastSeen = pairIndex;
571
+ region.weight *= Math.pow(DECAY_LAMBDA, gap);
572
+ matched.add(bestRegionIdx);
573
+ if (region.consecutiveCount >= this.animationThreshold) animationIndices.add(bi);
574
+ } else this.regions.push({
575
+ box,
576
+ consecutiveCount: 1,
577
+ firstSeen: pairIndex,
578
+ lastSeen: pairIndex,
579
+ weight: 1
580
+ });
581
+ }
582
+ for (let ri = 0; ri < this.regions.length; ri++) if (!matched.has(ri)) {
583
+ const gap = pairIndex - this.regions[ri].lastSeen;
584
+ this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
585
+ }
586
+ for (let i = 0; i < this.regions.length; i++) {
587
+ const region = this.regions[i];
588
+ if (region.weight <= .01 && !matched.has(i)) this.collectAnimation(region);
589
+ }
590
+ this.regions = filter(this.regions, (r, i) => r.weight > .01 || matched.has(i));
591
+ return animationIndices;
592
+ }
593
+ collectAnimation(region) {
594
+ if (region.consecutiveCount >= this.animationThreshold) {
595
+ const durationMs = region.consecutiveCount / this.fps * 1e3;
596
+ this.extractedAnimations.push({
597
+ type: "loading_spinner",
598
+ boundingBox: region.box,
599
+ startFrameId: region.firstSeen,
600
+ endFrameId: region.lastSeen,
601
+ durationMs
602
+ });
603
+ }
604
+ }
605
+ flushAndGetAnimations() {
606
+ for (const region of this.regions) this.collectAnimation(region);
607
+ this.regions = [];
608
+ return this.extractedAnimations;
609
+ }
610
+ getAnimationWeight(boxIndex, boxes) {
611
+ if (boxIndex >= boxes.length) return 0;
612
+ const box = boxes[boxIndex];
613
+ let maxWeight = 0;
614
+ for (const region of this.regions) if (region.consecutiveCount >= this.animationThreshold) {
615
+ if (computeIoU(box, region.box) > this.iouThreshold) maxWeight = Math.max(maxWeight, region.weight);
616
+ }
617
+ return maxWeight;
618
+ }
619
+ };
620
+ }));
621
+
622
+ //#endregion
623
+ //#region src/core/analyzer/index.ts
624
+ var init_analyzer = __esmMin((() => {
625
+ init_analyzer$1();
626
+ init_dbscan();
627
+ }));
628
+
629
+ //#endregion
630
+ //#region src/core/utils/metadata/build-video-metadata.ts
631
+ /**
632
+ * Read output dimensions and build consistent video and animation metadata.
633
+ * JPEG finalization does not resize, so the source dimensions match the output.
634
+ * @param ctx Pipeline state with source duration, effective FPS and analysis-space animations.
635
+ * @param selected Selected frames in output order; the first candidate is the fallback.
636
+ * @param analysisResolution Analysis dimensions; absent or zero dimensions imply no scaling.
637
+ * @returns Video metadata and new output-space animations, retaining zero-based frame IDs.
638
+ * @throws If sharp cannot read the selected or fallback image. Empty input performs no image I/O.
639
+ */
640
+ async function buildVideoMetadata(ctx, selected, analysisResolution) {
641
+ const firstFrame = selected[0] ?? ctx.frames[0];
642
+ const dimensions = firstFrame ? await sharp(firstFrame.extractPath).metadata() : void 0;
643
+ const width = dimensions?.width ?? 0;
644
+ const height = dimensions?.height ?? 0;
645
+ const sx = analysisResolution?.width ? width / analysisResolution.width : 1;
646
+ const sy = analysisResolution?.height ? height / analysisResolution.height : 1;
647
+ const lastTimestamp = ctx.frames[ctx.frames.length - 1]?.timestamp ?? 0;
648
+ const duration = ctx.options.mode === "frames" ? lastTimestamp : ctx.sourceDurationSec ?? lastTimestamp;
649
+ return {
650
+ video: {
651
+ originalDurationMs: Math.round(duration * 1e3),
652
+ fps: ctx.options.mode === "frames" ? 1 : ctx.effectiveFps ?? ctx.options.fps,
653
+ resolution: {
654
+ width,
655
+ height
656
+ }
657
+ },
658
+ animations: (ctx.animations ?? []).map((animation) => {
659
+ const box = animation.boundingBox;
660
+ const x = Math.max(0, Math.min(width, Math.round(box.x * sx)));
661
+ const y = Math.max(0, Math.min(height, Math.round(box.y * sy)));
662
+ return {
663
+ ...animation,
664
+ boundingBox: {
665
+ x,
666
+ y,
667
+ width: Math.max(0, Math.min(width - x, Math.round(box.width * sx))),
668
+ height: Math.max(0, Math.min(height - y, Math.round(box.height * sy)))
669
+ }
670
+ };
671
+ })
480
672
  };
481
673
  }
674
+ var init_build_video_metadata = __esmMin((() => {}));
675
+
676
+ //#endregion
677
+ //#region src/core/constants/workspace-layout.ts
678
+ function getTempWorkspaceDir(sessionId) {
679
+ return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
680
+ }
681
+ var APP_NAME, WORKSPACE_PREFIX, TEMP_BASE_DIR, FRAME_OUTPUT_EXTENSION, FRAME_FILENAME_PATTERN;
682
+ var init_workspace_layout = __esmMin((() => {
683
+ APP_NAME = "scene-sieve";
684
+ WORKSPACE_PREFIX = `${APP_NAME}-`;
685
+ TEMP_BASE_DIR = tmpdir();
686
+ FRAME_OUTPUT_EXTENSION = ".jpg";
687
+ FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
688
+ }));
482
689
 
483
690
  //#endregion
484
- //#region src/utils/paths.ts
691
+ //#region src/core/utils/filesystem/paths.ts
485
692
  async function ensureDir(dirPath) {
486
693
  await mkdir(dirPath, { recursive: true });
487
694
  }
@@ -517,15 +724,21 @@ function resolveAbsolute(p) {
517
724
  function deriveOutputPath(inputPath) {
518
725
  return resolve(resolve(inputPath, ".."), `${basename(inputPath, extname(inputPath))}_scenes`);
519
726
  }
727
+ var init_paths = __esmMin((() => {}));
520
728
 
521
729
  //#endregion
522
- //#region src/core/extractor.ts
730
+ //#region src/core/extractor/extractor.ts
523
731
  /**
524
732
  * Extract frames from video/GIF using FFmpeg.
525
- * Always uses FPS-based extraction. For long videos, FPS is automatically
526
- * reduced to stay within maxFrames budget.
733
+ * @param ctx Pipeline context; records effectiveFps and sourceDurationSec for video input.
734
+ * @returns Extracted candidates, or the unchanged input array in frames mode.
735
+ * @throws When the input is missing, metadata has no video stream, or FFmpeg fails.
527
736
  */
528
737
  async function extractFrames(ctx) {
738
+ if (ctx.options.mode === "frames") {
739
+ ctx.effectiveFps = 1;
740
+ return ctx.frames;
741
+ }
529
742
  const framesDir = join(ctx.workspacePath, "frames");
530
743
  const { inputPath, fps, maxFrames, scale } = ctx.options;
531
744
  if (!inputPath) throw new Error("inputPath is required for frame extraction");
@@ -540,19 +753,30 @@ async function extractFrames(ctx) {
540
753
  if (!(metadata.streams?.some((s) => s.codec_type === "video") ?? false)) throw new Error(`No video stream found in file: ${inputPath} (detected format: ${formatName})`);
541
754
  logger.debug(`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`);
542
755
  await ensureDir(framesDir);
756
+ const frameLimit = Math.max(2, maxFrames);
543
757
  let effectiveFps = fps;
544
758
  if (duration > 0) {
545
- const fpsCap = maxFrames / duration;
759
+ const fpsCap = frameLimit / duration;
546
760
  effectiveFps = Math.min(fps, fpsCap);
547
- effectiveFps = Math.max(.5, effectiveFps);
548
761
  logger.debug(`FPS: ${fps} → effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`);
549
762
  }
550
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, duration);
763
+ ctx.effectiveFps = effectiveFps;
764
+ ctx.sourceDurationSec = duration;
765
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale, frameLimit);
551
766
  ctx.emitProgress(100);
552
767
  logger.debug(`Extracted ${frames.length} frames`);
553
768
  return frames;
554
769
  }
555
- async function extractByFps(inputPath, outputDir, fps, scale, duration) {
770
+ /**
771
+ * Write scaled JPEG candidates through the bundled FFmpeg runtime.
772
+ * @param inputPath Readable video input.
773
+ * @param outputDir Existing frame directory.
774
+ * @param fps Positive effective sampling frequency.
775
+ * @param scale Output image height.
776
+ * @param frameLimit Maximum number of output frames.
777
+ * @returns Candidates with local output-grid timestamps; rejects on extraction failure.
778
+ */
779
+ async function extractByFps(inputPath, outputDir, fps, scale, frameLimit) {
556
780
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
557
781
  await execa(ffmpegPath, [
558
782
  "-i",
@@ -561,9 +785,11 @@ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
561
785
  `fps=${fps},scale=-1:${scale}`,
562
786
  "-q:v",
563
787
  "2",
788
+ "-frames:v",
789
+ String(frameLimit),
564
790
  outputPattern
565
791
  ]);
566
- return buildFrameList(outputDir, duration);
792
+ return buildFrameList(outputDir, fps);
567
793
  }
568
794
  async function getVideoMetadata(inputPath) {
569
795
  const { stdout } = await execa(path, [
@@ -577,12 +803,18 @@ async function getVideoMetadata(inputPath) {
577
803
  ]);
578
804
  return JSON.parse(stdout);
579
805
  }
580
- async function buildFrameList(framesDir, duration) {
806
+ /**
807
+ * Read sorted JPEG paths and attach local output-grid times.
808
+ * @param framesDir Extracted frame directory; filesystem errors propagate.
809
+ * @param effectiveFps Positive frequency used by the fps filter.
810
+ * @returns Zero-based candidates without a segment seek offset.
811
+ */
812
+ async function buildFrameList(framesDir, effectiveFps) {
581
813
  const jpgFiles = filter(await readdir(framesDir), (f) => f.endsWith(".jpg")).sort();
582
814
  if (jpgFiles.length === 0) return [];
583
815
  return map(jpgFiles, (file, index) => ({
584
816
  id: index,
585
- timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
817
+ timestamp: index / effectiveFps,
586
818
  extractPath: join(framesDir, file)
587
819
  }));
588
820
  }
@@ -596,9 +828,10 @@ async function buildFrameList(framesDir, duration) {
596
828
  * @param scale - Height scale for vision analysis
597
829
  * @param startTime - Start time in seconds
598
830
  * @param duration - Duration in seconds to extract
831
+ * @param frameLimit - Positive output limit; defaults to the range's grid capacity
599
832
  * @returns Array of FrameNode with segment-local timestamps (starting from 0)
600
833
  */
601
- async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
834
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration, frameLimit = Math.ceil(duration * fps)) {
602
835
  const outputPattern = join(outputDir, FRAME_FILENAME_PATTERN);
603
836
  await execa(ffmpegPath, [
604
837
  "-ss",
@@ -611,13 +844,124 @@ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime
611
844
  `fps=${fps},scale=-1:${scale}`,
612
845
  "-q:v",
613
846
  "2",
847
+ "-frames:v",
848
+ String(frameLimit),
614
849
  outputPattern
615
850
  ]);
616
- return buildFrameList(outputDir, duration);
851
+ return buildFrameList(outputDir, fps);
852
+ }
853
+ var init_extractor$1 = __esmMin((() => {
854
+ init_workspace_layout();
855
+ init_logger();
856
+ init_paths();
857
+ }));
858
+
859
+ //#endregion
860
+ //#region src/core/extractor/index.ts
861
+ var init_extractor = __esmMin((() => {
862
+ init_extractor$1();
863
+ }));
864
+
865
+ //#endregion
866
+ //#region src/core/input-resolver/validation/validate-options.ts
867
+ /**
868
+ * Reject invalid numeric options before defaults or pipeline effects are applied.
869
+ * @param options - Supplied options; omitted numeric fields use pipeline defaults.
870
+ * @returns Nothing when all supplied numeric fields satisfy their contracts.
871
+ * @throws An input error naming the invalid option and its received value.
872
+ */
873
+ function validateOptions(options) {
874
+ for (const [name, min, max, integer, exclusiveMin, requirement] of [
875
+ [
876
+ "count",
877
+ 1,
878
+ Infinity,
879
+ true,
880
+ false,
881
+ "an integer >= 1"
882
+ ],
883
+ [
884
+ "threshold",
885
+ 0,
886
+ 1,
887
+ false,
888
+ true,
889
+ "in range (0, 1] and finite"
890
+ ],
891
+ [
892
+ "fps",
893
+ 0,
894
+ Infinity,
895
+ false,
896
+ true,
897
+ "finite and > 0"
898
+ ],
899
+ [
900
+ "maxFrames",
901
+ 2,
902
+ Infinity,
903
+ true,
904
+ false,
905
+ "an integer >= 2"
906
+ ],
907
+ [
908
+ "scale",
909
+ 16,
910
+ Infinity,
911
+ true,
912
+ false,
913
+ "an integer >= 16"
914
+ ],
915
+ [
916
+ "quality",
917
+ 1,
918
+ 100,
919
+ true,
920
+ false,
921
+ "an integer in range [1, 100]"
922
+ ],
923
+ [
924
+ "iouThreshold",
925
+ 0,
926
+ 1,
927
+ false,
928
+ false,
929
+ "finite and in range [0, 1]"
930
+ ],
931
+ [
932
+ "animationThreshold",
933
+ 1,
934
+ Infinity,
935
+ true,
936
+ false,
937
+ "an integer >= 1"
938
+ ],
939
+ [
940
+ "maxSegmentDuration",
941
+ 0,
942
+ Infinity,
943
+ false,
944
+ true,
945
+ "finite and > 0"
946
+ ],
947
+ [
948
+ "concurrency",
949
+ 1,
950
+ Infinity,
951
+ true,
952
+ false,
953
+ "an integer >= 1"
954
+ ]
955
+ ]) {
956
+ const value = options[name];
957
+ if (value === void 0) continue;
958
+ if (!Number.isFinite(value) || integer && !Number.isInteger(value) || (exclusiveMin ? value <= min : value < min) || value > max) throw new Error(`${name} must be ${requirement}, received: ${value}`);
959
+ }
617
960
  }
961
+ var init_validate_options = __esmMin((() => {}));
618
962
 
619
963
  //#endregion
620
- //#region src/core/workspace.ts
964
+ //#region src/core/workspace/workspace.ts
621
965
  async function createWorkspace(sessionId) {
622
966
  const workspacePath = getTempWorkspaceDir(sessionId);
623
967
  await ensureDir(join(workspacePath, "frames"));
@@ -648,17 +992,11 @@ async function finalizeOutput(ctx, selectedFrames) {
648
992
  timestampMs: Math.round(frame.timestamp * 1e3)
649
993
  });
650
994
  }
995
+ const { video, animations } = await buildVideoMetadata(ctx, selectedFrames, ctx.analysisResolution);
651
996
  const metadata = {
652
- video: {
653
- originalDurationMs: Math.round((ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3),
654
- fps: ctx.options.fps,
655
- resolution: {
656
- width: ctx.options.scale,
657
- height: Math.round(ctx.options.scale * 9 / 16)
658
- }
659
- },
997
+ video,
660
998
  frames: framesMetadata,
661
- animations: map(ctx.animations || [], (anim) => ({
999
+ animations: map(animations, (anim) => ({
662
1000
  ...anim,
663
1001
  startFrameId: anim.startFrameId + 1,
664
1002
  endFrameId: anim.endFrameId + 1,
@@ -729,15 +1067,32 @@ async function readFramesAsBuffers(frameNodes, quality) {
729
1067
  mozjpeg: true
730
1068
  }).toBuffer()));
731
1069
  }
1070
+ var init_workspace$1 = __esmMin((() => {
1071
+ init_workspace_layout();
1072
+ init_paths();
1073
+ init_build_video_metadata();
1074
+ }));
732
1075
 
733
1076
  //#endregion
734
- //#region src/core/input-resolver.ts
1077
+ //#region src/core/workspace/index.ts
1078
+ var init_workspace = __esmMin((() => {
1079
+ init_workspace$1();
1080
+ }));
1081
+
1082
+ //#endregion
1083
+ //#region src/core/input-resolver/input-resolver.ts
1084
+ /**
1085
+ * Validate supplied options and resolve defaults and paths for the pipeline.
1086
+ * @param options - Mode-specific input and optional numeric settings.
1087
+ * @returns Complete pipeline settings with absolute file input paths.
1088
+ * @throws An input error if a supplied numeric setting is invalid.
1089
+ */
735
1090
  function resolveOptions(options) {
1091
+ validateOptions(options);
736
1092
  const mode = options.mode;
737
1093
  const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
738
1094
  const outputPath = options.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join(process.cwd(), "scene-sieve-output"));
739
1095
  const threshold = options.threshold ?? .5;
740
- if (threshold <= 0 || threshold > 1) throw new Error(`threshold must be in range (0, 1], received: ${threshold}`);
741
1096
  return {
742
1097
  mode,
743
1098
  inputPath,
@@ -762,6 +1117,10 @@ function resolveOptions(options) {
762
1117
  * - 'file' mode: validate file exists and delegate to extractor (caller's responsibility)
763
1118
  * - 'buffer' mode: write buffer as temp video file, return path via FrameNode trick (empty list)
764
1119
  * - 'frames' mode: write frame buffers as JPGs, return FrameNode[]
1120
+ * @param options - Input source; encoded frames must have matching dimensions.
1121
+ * @param workspacePath - Workspace receiving temporary input files.
1122
+ * @returns Frame nodes or a resolved video path for extraction.
1123
+ * @throws Propagates metadata or write errors and rejects mismatched frame sizes.
765
1124
  */
766
1125
  async function resolveInput(options, workspacePath) {
767
1126
  if (options.mode === "file") return {
@@ -772,12 +1131,51 @@ async function resolveInput(options, workspacePath) {
772
1131
  frames: [],
773
1132
  resolvedInputPath: await writeInputBuffer(options.inputBuffer, workspacePath)
774
1133
  };
775
- if (options.mode === "frames") return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1134
+ if (options.mode === "frames") {
1135
+ let dimensions;
1136
+ for (const buffer of options.inputFrames) {
1137
+ const { width, height } = await sharp(buffer).metadata();
1138
+ if (dimensions && (width !== dimensions.width || height !== dimensions.height)) throw new Error(`inputFrames must be the same size (${dimensions.width}x${dimensions.height}), received: ${width}x${height}`);
1139
+ dimensions = {
1140
+ width,
1141
+ height
1142
+ };
1143
+ }
1144
+ return { frames: await writeInputFrames(options.inputFrames, workspacePath) };
1145
+ }
776
1146
  throw new Error(`Unsupported input mode: ${options.mode}`);
777
1147
  }
1148
+ var init_input_resolver$1 = __esmMin((() => {
1149
+ init_pipeline_defaults();
1150
+ init_paths();
1151
+ init_validate_options();
1152
+ init_workspace();
1153
+ }));
778
1154
 
779
1155
  //#endregion
780
- //#region src/utils/math.ts
1156
+ //#region src/core/input-resolver/index.ts
1157
+ var init_input_resolver = __esmMin((() => {
1158
+ init_input_resolver$1();
1159
+ }));
1160
+
1161
+ //#endregion
1162
+ //#region src/core/pruner/scoring/normalize-scores.ts
1163
+ /**
1164
+ * Find the first position whose score is at least the requested value.
1165
+ * @param sorted - Finite positive scores sorted in ascending order.
1166
+ * @param value - A finite positive score present in sorted.
1167
+ * @returns The first matching rank, including the first position of any tie.
1168
+ */
1169
+ function lowerBound(sorted, value) {
1170
+ let low = 0;
1171
+ let high = sorted.length;
1172
+ while (low < high) {
1173
+ const mid = Math.floor((low + high) / 2);
1174
+ if (sorted[mid] < value) low = mid + 1;
1175
+ else high = mid;
1176
+ }
1177
+ return low;
1178
+ }
781
1179
  /**
782
1180
  * Normalize raw scores to [0, 1] range via Robust Hybrid Normalization.
783
1181
  *
@@ -822,64 +1220,67 @@ function normalizeScores(items) {
822
1220
  });
823
1221
  const cdf = map(safeScores, (s) => {
824
1222
  if (s <= 0) return 0;
825
- return sorted.findIndex((v) => v >= s) / sorted.length;
1223
+ return lowerBound(sorted, s) / sorted.length;
826
1224
  });
827
1225
  return map(logisticZ, (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA);
828
1226
  }
1227
+ var NORMALIZATION_ALPHA, NORMALIZATION_MAD_COEFFICIENT, NORMALIZATION_MIN_SAMPLE_SIZE;
1228
+ var init_normalize_scores = __esmMin((() => {
1229
+ NORMALIZATION_ALPHA = .4;
1230
+ NORMALIZATION_MAD_COEFFICIENT = 1.4826;
1231
+ NORMALIZATION_MIN_SAMPLE_SIZE = 10;
1232
+ }));
829
1233
 
830
1234
  //#endregion
831
- //#region src/utils/min-heap.ts
832
- /**
833
- * Generic binary min-heap.
834
- *
835
- * Elements are ordered by a numeric `score` field.
836
- * push/pop: O(log N), size: O(1).
837
- */
838
- var MinHeap = class {
839
- h = [];
840
- get size() {
841
- return this.h.length;
842
- }
843
- push(entry) {
844
- this.h.push(entry);
845
- this.siftUp(this.h.length - 1);
846
- }
847
- pop() {
848
- const n = this.h.length;
849
- if (n === 0) return void 0;
850
- const top = this.h[0];
851
- const last = this.h.pop();
852
- if (n > 1) {
853
- this.h[0] = last;
854
- this.siftDown(0);
1235
+ //#region src/core/pruner/heap/min-heap.ts
1236
+ var MinHeap;
1237
+ var init_min_heap = __esmMin((() => {
1238
+ MinHeap = class {
1239
+ h = [];
1240
+ get size() {
1241
+ return this.h.length;
855
1242
  }
856
- return top;
857
- }
858
- siftUp(i) {
859
- while (i > 0) {
860
- const p = i - 1 >> 1;
861
- if (this.h[p].score <= this.h[i].score) break;
862
- [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
863
- i = p;
1243
+ push(entry) {
1244
+ this.h.push(entry);
1245
+ this.siftUp(this.h.length - 1);
864
1246
  }
865
- }
866
- siftDown(i) {
867
- const n = this.h.length;
868
- for (;;) {
869
- let m = i;
870
- const l = 2 * i + 1;
871
- const r = 2 * i + 2;
872
- if (l < n && this.h[l].score < this.h[m].score) m = l;
873
- if (r < n && this.h[r].score < this.h[m].score) m = r;
874
- if (m === i) break;
875
- [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
876
- i = m;
1247
+ pop() {
1248
+ const n = this.h.length;
1249
+ if (n === 0) return void 0;
1250
+ const top = this.h[0];
1251
+ const last = this.h.pop();
1252
+ if (n > 1) {
1253
+ this.h[0] = last;
1254
+ this.siftDown(0);
1255
+ }
1256
+ return top;
877
1257
  }
878
- }
879
- };
1258
+ siftUp(i) {
1259
+ while (i > 0) {
1260
+ const p = i - 1 >> 1;
1261
+ if (this.h[p].score <= this.h[i].score) break;
1262
+ [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
1263
+ i = p;
1264
+ }
1265
+ }
1266
+ siftDown(i) {
1267
+ const n = this.h.length;
1268
+ for (;;) {
1269
+ let m = i;
1270
+ const l = 2 * i + 1;
1271
+ const r = 2 * i + 2;
1272
+ if (l < n && this.h[l].score < this.h[m].score) m = l;
1273
+ if (r < n && this.h[r].score < this.h[m].score) m = r;
1274
+ if (m === i) break;
1275
+ [this.h[m], this.h[i]] = [this.h[i], this.h[m]];
1276
+ i = m;
1277
+ }
1278
+ }
1279
+ };
1280
+ }));
880
1281
 
881
1282
  //#endregion
882
- //#region src/core/pruner.ts
1283
+ //#region src/core/pruner/pruner.ts
883
1284
  /**
884
1285
  * Edge-aware greedy merge with re-linking — O(N log N).
885
1286
  *
@@ -993,7 +1394,7 @@ function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
993
1394
  return result;
994
1395
  }
995
1396
  /**
996
- * Threshold-based pruning with NMS -- O(N).
1397
+ * Threshold-based pruning with NMS -- including normalization, O(N log N).
997
1398
  *
998
1399
  * 1. Scores are normalized to [0, 1] via percentile normalization.
999
1400
  * 2. Edges with normalized score >= threshold are collected.
@@ -1056,9 +1457,19 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1056
1457
  }
1057
1458
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1058
1459
  }
1460
+ var init_pruner$1 = __esmMin((() => {
1461
+ init_normalize_scores();
1462
+ init_min_heap();
1463
+ }));
1059
1464
 
1060
1465
  //#endregion
1061
- //#region src/utils/concurrency.ts
1466
+ //#region src/core/pruner/index.ts
1467
+ var init_pruner = __esmMin((() => {
1468
+ init_pruner$1();
1469
+ }));
1470
+
1471
+ //#endregion
1472
+ //#region src/core/segmenter/scheduling/concurrency.ts
1062
1473
  /**
1063
1474
  * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
1064
1475
  * Lightweight replacement for p-limit to avoid external dependency.
@@ -1078,9 +1489,10 @@ function concurrencyLimit(limit) {
1078
1489
  }
1079
1490
  };
1080
1491
  }
1492
+ var init_concurrency = __esmMin((() => {}));
1081
1493
 
1082
1494
  //#endregion
1083
- //#region src/core/segmenter.ts
1495
+ //#region src/core/segmenter/segmenter.ts
1084
1496
  /**
1085
1497
  * Determine whether segmentation should be used.
1086
1498
  * Returns false for frames mode and GIF files.
@@ -1094,53 +1506,62 @@ function shouldSegment(resolvedOptions, originalOptions) {
1094
1506
  return true;
1095
1507
  }
1096
1508
  /**
1097
- * Compute segment boundaries with overlap, frame allocation, and effectiveFps.
1098
- * Pure function no I/O.
1099
- *
1100
- * - effectiveFps is uniform across all segments
1101
- * - Overlap: 1 frame at each internal boundary
1102
- * - allocatedFrames total <= maxFrames (last segment adjusted if needed)
1509
+ * Partition the global extraction grid into nonempty logical segments.
1510
+ * @param totalDuration Positive source duration in seconds.
1511
+ * @param maxSegmentDuration Positive logical segment width in seconds.
1512
+ * @param maxFrames Candidate budget, defensively raised to at least two.
1513
+ * @param fps Positive requested sampling frequency.
1514
+ * @returns Contiguous plan indices with grid-aligned seeks and overlap-inclusive limits.
1103
1515
  */
1104
1516
  function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1105
- const effectiveFps = Math.max(.5, Math.min(fps, maxFrames / totalDuration));
1517
+ const frameLimit = Math.max(2, maxFrames);
1518
+ const effectiveFps = Math.min(fps, frameLimit / totalDuration);
1106
1519
  if (totalDuration <= maxSegmentDuration) return [{
1107
1520
  index: 0,
1108
1521
  startTime: 0,
1109
1522
  endTime: totalDuration,
1110
1523
  duration: totalDuration,
1111
- allocatedFrames: Math.min(Math.ceil(effectiveFps * totalDuration), maxFrames),
1524
+ allocatedFrames: frameLimit,
1112
1525
  effectiveFps,
1113
1526
  overlapBefore: 0,
1114
1527
  overlapAfter: 0,
1115
1528
  extractStartTime: 0,
1116
1529
  extractDuration: totalDuration
1117
1530
  }];
1118
- const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1119
- const overlapTime = 1 / effectiveFps;
1120
1531
  const segments = [];
1121
- for (let i = 0; i < segmentCount; i++) {
1122
- const startTime = i * maxSegmentDuration;
1123
- const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1124
- const duration = endTime - startTime;
1125
- const overlapBefore = i > 0 ? 1 : 0;
1126
- const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1127
- const extractStartTime = Math.max(0, startTime - overlapBefore * overlapTime);
1128
- const extractDuration = Math.min(totalDuration, endTime + overlapAfter * overlapTime) - extractStartTime;
1532
+ for (let slot = 0; slot < frameLimit; slot++) {
1533
+ const timestamp = slot / effectiveFps;
1534
+ if (timestamp >= totalDuration) break;
1535
+ const startTime = Math.floor(timestamp / maxSegmentDuration) * maxSegmentDuration;
1536
+ const previous = segments[segments.length - 1];
1537
+ if (previous?.startTime === startTime) {
1538
+ previous.allocatedFrames++;
1539
+ continue;
1540
+ }
1541
+ const endTime = Math.min(startTime + maxSegmentDuration, totalDuration);
1129
1542
  segments.push({
1130
- index: i,
1543
+ index: segments.length,
1131
1544
  startTime,
1132
1545
  endTime,
1133
- duration,
1134
- allocatedFrames: Math.ceil(effectiveFps * duration),
1546
+ duration: endTime - startTime,
1547
+ allocatedFrames: 1,
1135
1548
  effectiveFps,
1136
- overlapBefore,
1137
- overlapAfter,
1138
- extractStartTime,
1139
- extractDuration
1549
+ overlapBefore: 0,
1550
+ overlapAfter: 0,
1551
+ extractStartTime: timestamp,
1552
+ extractDuration: 0
1140
1553
  });
1141
1554
  }
1142
- const totalAllocated = segments.reduce((sum, s) => sum + s.allocatedFrames, 0);
1143
- if (totalAllocated > maxFrames) segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1555
+ let firstSlot = 0;
1556
+ for (const segment of segments) {
1557
+ const nextSlot = firstSlot + segment.allocatedFrames;
1558
+ segment.overlapBefore = segment.index > 0 ? 1 : 0;
1559
+ segment.overlapAfter = segment.index < segments.length - 1 ? 1 : 0;
1560
+ segment.extractStartTime = (firstSlot - segment.overlapBefore) / effectiveFps;
1561
+ segment.extractDuration = (segment.overlapAfter ? Math.min(totalDuration, (nextSlot + 1) / effectiveFps) : totalDuration) - segment.extractStartTime;
1562
+ segment.allocatedFrames += segment.overlapBefore + segment.overlapAfter;
1563
+ firstSlot = nextSlot;
1564
+ }
1144
1565
  return segments;
1145
1566
  }
1146
1567
  /**
@@ -1159,44 +1580,58 @@ function collectAllFrames(segmentResults) {
1159
1580
  return allFrames;
1160
1581
  }
1161
1582
  /**
1162
- * Sort frames by timestamp then remove overlap duplicates.
1163
- * Threshold: 1/(effectiveFps * 2) adaptive to fps (Section 18 note 5).
1164
- * Keeps the first occurrence (earlier segment).
1583
+ * Sort frames in place and alias overlap duplicates to the first survivor.
1584
+ * @param frames Collected entries whose segment index and local ID identify a frame.
1585
+ * @param effectiveFps Positive sampling frequency; half a frame interval is the threshold.
1586
+ * @returns Timestamp-ordered survivors and duplicate keys pointing directly to survivor keys.
1165
1587
  */
1166
1588
  function deduplicateFrames(frames, effectiveFps) {
1167
1589
  frames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1168
1590
  const dupThreshold = 1 / (effectiveFps * 2);
1169
1591
  const unique = [];
1592
+ const aliases = /* @__PURE__ */ new Map();
1170
1593
  for (const entry of frames) {
1171
1594
  if (unique.length > 0) {
1172
1595
  const last = unique[unique.length - 1];
1173
- if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) continue;
1596
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1597
+ aliases.set(`${entry.segmentIndex}:${entry.localId}`, `${last.segmentIndex}:${last.localId}`);
1598
+ continue;
1599
+ }
1174
1600
  }
1175
1601
  unique.push(entry);
1176
1602
  }
1177
- return unique;
1603
+ return {
1604
+ unique,
1605
+ aliases
1606
+ };
1178
1607
  }
1179
1608
  /**
1180
- * Assign sequential global IDs to deduplicated frames and build a lookup map.
1181
- * Returns the remapped FrameNode array and the "segmentIndex:localId" -> globalId map.
1609
+ * Assign sequential global IDs and retain duplicate local IDs as aliases.
1610
+ * @param uniqueFrames Timestamp-ordered survivors with distinct segment/local keys.
1611
+ * @param aliases Duplicate keys pointing directly to keys in uniqueFrames.
1612
+ * @returns Remapped frames and a global ID lookup covering survivors and duplicates.
1182
1613
  */
1183
- function remapFrameIds(uniqueFrames) {
1614
+ function remapFrameIds(uniqueFrames, aliases) {
1184
1615
  const globalIdMap = /* @__PURE__ */ new Map();
1616
+ const frames = uniqueFrames.map((entry, globalId) => {
1617
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1618
+ return {
1619
+ id: globalId,
1620
+ timestamp: entry.frame.timestamp,
1621
+ extractPath: entry.frame.extractPath
1622
+ };
1623
+ });
1624
+ for (const [alias, survivor] of aliases) globalIdMap.set(alias, globalIdMap.get(survivor));
1185
1625
  return {
1186
- frames: uniqueFrames.map((entry, globalId) => {
1187
- globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1188
- return {
1189
- id: globalId,
1190
- timestamp: entry.frame.timestamp,
1191
- extractPath: entry.frame.extractPath
1192
- };
1193
- }),
1626
+ frames,
1194
1627
  globalIdMap
1195
1628
  };
1196
1629
  }
1197
1630
  /**
1198
- * Remap edge source/target IDs using the global ID map.
1199
- * Duplicate edges (same source-target pair) retain the higher score.
1631
+ * Remap edges, dropping missing endpoints and self loops while keeping the highest pair score.
1632
+ * @param segmentResults Segment-local edges in encounter order.
1633
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1634
+ * @returns One edge per surviving directed pair without changing its score.
1200
1635
  */
1201
1636
  function remapEdges(segmentResults, globalIdMap) {
1202
1637
  const edges = [];
@@ -1205,6 +1640,7 @@ function remapEdges(segmentResults, globalIdMap) {
1205
1640
  const newSourceId = globalIdMap.get(`${result.segment.index}:${edge.sourceId}`);
1206
1641
  const newTargetId = globalIdMap.get(`${result.segment.index}:${edge.targetId}`);
1207
1642
  if (newSourceId === void 0 || newTargetId === void 0) continue;
1643
+ if (newSourceId === newTargetId) continue;
1208
1644
  const edgeKey = `${newSourceId}-${newTargetId}`;
1209
1645
  const existingIdx = edgeMap.get(edgeKey);
1210
1646
  if (existingIdx !== void 0) {
@@ -1225,42 +1661,52 @@ function remapEdges(segmentResults, globalIdMap) {
1225
1661
  return edges;
1226
1662
  }
1227
1663
  /**
1228
- * Remap animation startFrameId/endFrameId using the global ID map.
1229
- * Animations whose frame IDs were deduplicated (not in map) are dropped.
1664
+ * Remap animations, dropping missing or collapsed endpoints and retaining the first pair entry.
1665
+ * @param segmentResults Segment-local tracker entries in encounter order.
1666
+ * @param globalIdMap Survivor and duplicate local keys mapped to global IDs.
1667
+ * @returns One animation per directed pair with its original tracker duration and metadata.
1230
1668
  */
1231
1669
  function remapAnimations(segmentResults, globalIdMap) {
1232
- const animations = [];
1670
+ const animations = /* @__PURE__ */ new Map();
1233
1671
  for (const result of segmentResults) for (const anim of result.animations) {
1234
1672
  const newStartId = globalIdMap.get(`${result.segment.index}:${anim.startFrameId}`);
1235
1673
  const newEndId = globalIdMap.get(`${result.segment.index}:${anim.endFrameId}`);
1236
1674
  if (newStartId === void 0 || newEndId === void 0) continue;
1237
- animations.push({
1675
+ if (newStartId === newEndId) continue;
1676
+ const animationKey = `${newStartId}-${newEndId}`;
1677
+ if (animations.has(animationKey)) continue;
1678
+ animations.set(animationKey, {
1238
1679
  ...anim,
1239
1680
  startFrameId: newStartId,
1240
1681
  endFrameId: newEndId
1241
1682
  });
1242
1683
  }
1243
- return animations;
1684
+ return [...animations.values()];
1244
1685
  }
1245
1686
  /**
1246
1687
  * Merge multiple segment results into a single unified frame/edge/animation set.
1247
- * - Timestamps adjusted using extractStartTime (Section 18 note 1)
1248
- * - Overlap frames deduplicated by threshold 1/(effectiveFps*2) (Section 18 note 5)
1249
- * - Global IDs reassigned after dedup
1250
- * - Duplicate edges keep higher score
1688
+ * @param segmentResults Local frames, edges and tracker entries with distinct segment indices.
1689
+ * @returns Global timestamp-ordered frames, aliased edges and animations without self loops.
1690
+ * Duplicate edges keep the higher score; duplicate animations keep the first tracker entry.
1251
1691
  */
1252
1692
  function mergeSegmentFrames(segmentResults) {
1253
1693
  if (segmentResults.length === 0) return {
1254
1694
  frames: [],
1255
1695
  edges: [],
1256
- animations: []
1696
+ animations: [],
1697
+ analysisResolution: {
1698
+ width: 0,
1699
+ height: 0
1700
+ }
1257
1701
  };
1258
1702
  const effectiveFps = segmentResults[0].segment.effectiveFps;
1259
- const { frames, globalIdMap } = remapFrameIds(deduplicateFrames(collectAllFrames(segmentResults), effectiveFps));
1703
+ const { unique, aliases } = deduplicateFrames(collectAllFrames(segmentResults), effectiveFps);
1704
+ const { frames, globalIdMap } = remapFrameIds(unique, aliases);
1260
1705
  return {
1261
1706
  frames,
1262
1707
  edges: remapEdges(segmentResults, globalIdMap),
1263
- animations: remapAnimations(segmentResults, globalIdMap)
1708
+ animations: remapAnimations(segmentResults, globalIdMap),
1709
+ analysisResolution: segmentResults[0].analysisResolution
1264
1710
  };
1265
1711
  }
1266
1712
  function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
@@ -1271,6 +1717,7 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1271
1717
  maxFrames: segment.allocatedFrames
1272
1718
  },
1273
1719
  workspacePath: segmentWorkspacePath,
1720
+ effectiveFps: segment.effectiveFps,
1274
1721
  frames,
1275
1722
  graph: [],
1276
1723
  status: "ANALYZING",
@@ -1282,19 +1729,24 @@ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOpti
1282
1729
  * Each segment uses an isolated workspace directory.
1283
1730
  */
1284
1731
  async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1285
- const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration);
1732
+ const frames = await extractFramesForRange(inputPath, join(workspacePath, "frames"), segment.effectiveFps, resolvedOptions.scale, segment.extractStartTime, segment.extractDuration, segment.allocatedFrames);
1286
1733
  if (frames.length < 2) return {
1287
1734
  segment,
1288
1735
  frames,
1289
1736
  edges: [],
1290
- animations: []
1737
+ animations: [],
1738
+ analysisResolution: {
1739
+ width: 0,
1740
+ height: 0
1741
+ }
1291
1742
  };
1292
- const { edges, animations } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1743
+ const { edges, animations, analysisResolution } = await analyzeFrames(buildSegmentContext(segment, frames, workspacePath, resolvedOptions, onProgress));
1293
1744
  return {
1294
1745
  segment,
1295
1746
  frames,
1296
1747
  edges,
1297
- animations
1748
+ animations,
1749
+ analysisResolution
1298
1750
  };
1299
1751
  }
1300
1752
  /**
@@ -1334,7 +1786,7 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1334
1786
  });
1335
1787
  })));
1336
1788
  options.onProgress?.("ANALYZING", 100);
1337
- const { frames, edges, animations } = mergeSegmentFrames(results);
1789
+ const { frames, edges, animations, analysisResolution } = mergeSegmentFrames(results);
1338
1790
  logger.debug(`Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`);
1339
1791
  options.onProgress?.("PRUNING", 0);
1340
1792
  const survivingIds = pruneByThresholdWithCap(edges, frames, resolvedOptions.threshold, resolvedOptions.count);
@@ -1343,6 +1795,9 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1343
1795
  options.onProgress?.("FINALIZING", 0);
1344
1796
  const ctx = {
1345
1797
  options: resolvedOptions,
1798
+ effectiveFps: segments[0]?.effectiveFps,
1799
+ sourceDurationSec: totalDuration,
1800
+ analysisResolution,
1346
1801
  workspacePath: mainWorkspace,
1347
1802
  frames,
1348
1803
  graph: edges,
@@ -1356,21 +1811,14 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1356
1811
  else outputFiles = await finalizeOutput(ctx, prunedFrames);
1357
1812
  options.onProgress?.("FINALIZING", 100);
1358
1813
  logger.success(`Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`);
1814
+ const outputMetadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1359
1815
  return {
1360
1816
  success: true,
1361
1817
  originalFramesCount: frames.length,
1362
1818
  prunedFramesCount: prunedFrames.length,
1363
1819
  outputFiles,
1364
1820
  outputBuffers,
1365
- animations,
1366
- video: {
1367
- originalDurationMs: totalDuration * 1e3,
1368
- fps: resolvedOptions.fps,
1369
- resolution: {
1370
- width: resolvedOptions.scale,
1371
- height: Math.round(resolvedOptions.scale * 9 / 16)
1372
- }
1373
- },
1821
+ ...outputMetadata,
1374
1822
  executionTimeMs: Date.now() - pipelineStart
1375
1823
  };
1376
1824
  } catch (error) {
@@ -1382,11 +1830,27 @@ async function runSegmentedPipeline(options, resolvedOptions) {
1382
1830
  else logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1383
1831
  }
1384
1832
  }
1833
+ var init_segmenter$1 = __esmMin((() => {
1834
+ init_concurrency();
1835
+ init_logger();
1836
+ init_analyzer();
1837
+ init_build_video_metadata();
1838
+ init_extractor();
1839
+ init_input_resolver();
1840
+ init_pruner();
1841
+ init_workspace();
1842
+ }));
1843
+
1844
+ //#endregion
1845
+ //#region src/core/segmenter/index.ts
1846
+ var init_segmenter = __esmMin((() => {
1847
+ init_segmenter$1();
1848
+ }));
1385
1849
 
1386
1850
  //#endregion
1387
- //#region src/core/orchestrator.ts
1851
+ //#region src/core/orchestrator/orchestrator.ts
1388
1852
  async function runPipeline(options) {
1389
- if (options.debug ?? false) setDebugMode(true);
1853
+ setDebugMode(options.debug ?? false);
1390
1854
  const resolvedOptions = resolveOptions(options);
1391
1855
  if (shouldSegment(resolvedOptions, options)) return runSegmentedPipeline(options, resolvedOptions);
1392
1856
  const startTime = Date.now();
@@ -1406,19 +1870,27 @@ async function runPipeline(options) {
1406
1870
  logger.debug(`Workspace created: ${ctx.workspacePath}`);
1407
1871
  ctx.status = "EXTRACTING";
1408
1872
  const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(options, ctx.workspacePath);
1409
- if (resolvedOptions.mode === "frames") ctx.frames = resolvedFrames;
1410
- else ctx.frames = await extractFrames({
1411
- ...ctx,
1412
- options: {
1413
- ...resolvedOptions,
1414
- inputPath: resolvedInputPath
1415
- }
1416
- });
1873
+ if (resolvedOptions.mode === "frames") {
1874
+ ctx.frames = resolvedFrames;
1875
+ ctx.effectiveFps = 1;
1876
+ } else {
1877
+ const extractCtx = {
1878
+ ...ctx,
1879
+ options: {
1880
+ ...resolvedOptions,
1881
+ inputPath: resolvedInputPath
1882
+ }
1883
+ };
1884
+ ctx.frames = await extractFrames(extractCtx);
1885
+ ctx.effectiveFps = extractCtx.effectiveFps;
1886
+ ctx.sourceDurationSec = extractCtx.sourceDurationSec;
1887
+ }
1417
1888
  ctx.emitProgress(100);
1418
1889
  ctx.status = "ANALYZING";
1419
- const { edges, animations } = await analyzeFrames(ctx);
1890
+ const { edges, animations, analysisResolution } = await analyzeFrames(ctx);
1420
1891
  ctx.graph = edges;
1421
1892
  ctx.animations = animations;
1893
+ ctx.analysisResolution = analysisResolution;
1422
1894
  ctx.status = "PRUNING";
1423
1895
  const survivingIds = pruneByThresholdWithCap(ctx.graph, ctx.frames, resolvedOptions.threshold, resolvedOptions.count);
1424
1896
  const prunedFrames = filter(ctx.frames, (f) => survivingIds.has(f.id));
@@ -1435,21 +1907,14 @@ async function runPipeline(options) {
1435
1907
  }
1436
1908
  ctx.status = "SUCCESS";
1437
1909
  logger.success(`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`);
1910
+ const metadata = await buildVideoMetadata(ctx, prunedFrames, analysisResolution);
1438
1911
  return {
1439
1912
  success: true,
1440
1913
  originalFramesCount: ctx.frames.length,
1441
1914
  prunedFramesCount: prunedFrames.length,
1442
1915
  outputFiles,
1443
1916
  outputBuffers,
1444
- animations: ctx.animations,
1445
- video: {
1446
- originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1447
- fps: ctx.options.fps,
1448
- resolution: {
1449
- width: ctx.options.scale,
1450
- height: Math.round(ctx.options.scale * 9 / 16)
1451
- }
1452
- },
1917
+ ...metadata,
1453
1918
  executionTimeMs: Date.now() - startTime
1454
1919
  };
1455
1920
  } catch (error) {
@@ -1462,6 +1927,20 @@ async function runPipeline(options) {
1462
1927
  else logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
1463
1928
  }
1464
1929
  }
1930
+ var init_orchestrator = __esmMin((() => {
1931
+ init_logger();
1932
+ init_analyzer();
1933
+ init_build_video_metadata();
1934
+ init_extractor();
1935
+ init_input_resolver();
1936
+ init_pruner();
1937
+ init_segmenter();
1938
+ init_workspace();
1939
+ }));
1940
+
1941
+ //#endregion
1942
+ //#region src/index.ts
1943
+ init_orchestrator();
1465
1944
 
1466
1945
  //#endregion
1467
1946
  export { runPipeline as extractScenes };