@lumy-pack/scene-sieve 0.0.14 → 0.1.0

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