@lumy-pack/scene-sieve 0.0.4 → 0.0.6
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 +693 -255
- package/dist/commands/Sieve.d.ts +15 -0
- package/dist/components/PhaseStep.d.ts +14 -0
- package/dist/components/ProgressBar.d.ts +7 -0
- package/dist/constants.d.ts +1 -0
- package/dist/core/analyzer.d.ts +9 -2
- package/dist/core/extractor.d.ts +2 -2
- package/dist/core/pipeline-worker.d.ts +1 -0
- package/dist/core/run-in-worker.d.ts +9 -0
- package/dist/core/workspace.d.ts +5 -0
- package/dist/index.cjs +115 -44
- package/dist/index.mjs +116 -45
- package/dist/pipeline-worker.mjs +1136 -0
- package/dist/types/index.d.ts +28 -0
- package/package.json +5 -4
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
// src/core/pipeline-worker.ts
|
|
2
|
+
import { parentPort, workerData } from "worker_threads";
|
|
3
|
+
|
|
4
|
+
// src/core/orchestrator.ts
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
|
|
7
|
+
// src/utils/logger.ts
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
var debugMode = false;
|
|
10
|
+
function setDebugMode(enabled) {
|
|
11
|
+
debugMode = enabled;
|
|
12
|
+
}
|
|
13
|
+
function timestamp() {
|
|
14
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
15
|
+
}
|
|
16
|
+
var logger = {
|
|
17
|
+
info(message) {
|
|
18
|
+
console.log(`${pc.blue("info")} ${message}`);
|
|
19
|
+
},
|
|
20
|
+
success(message) {
|
|
21
|
+
console.log(`
|
|
22
|
+
${pc.green("done")} ${message}`);
|
|
23
|
+
},
|
|
24
|
+
warn(message) {
|
|
25
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
26
|
+
},
|
|
27
|
+
error(message) {
|
|
28
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
29
|
+
},
|
|
30
|
+
debug(message) {
|
|
31
|
+
if (debugMode) {
|
|
32
|
+
console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/core/analyzer.ts
|
|
38
|
+
import { createRequire } from "module";
|
|
39
|
+
import sharp from "sharp";
|
|
40
|
+
|
|
41
|
+
// src/constants.ts
|
|
42
|
+
import { tmpdir } from "os";
|
|
43
|
+
import { join } from "path";
|
|
44
|
+
var APP_NAME = "scene-sieve";
|
|
45
|
+
var DEFAULT_COUNT = 20;
|
|
46
|
+
var DEFAULT_THRESHOLD = 0.5;
|
|
47
|
+
var DEFAULT_FPS = 5;
|
|
48
|
+
var DEFAULT_SCALE = 720;
|
|
49
|
+
var DEFAULT_QUALITY = 80;
|
|
50
|
+
var DEFAULT_MAX_FRAMES = 300;
|
|
51
|
+
var NORMALIZATION_PERCENTILE = 0.9;
|
|
52
|
+
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
53
|
+
var TEMP_BASE_DIR = tmpdir();
|
|
54
|
+
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
55
|
+
".mp4",
|
|
56
|
+
".mov",
|
|
57
|
+
".avi",
|
|
58
|
+
".mkv",
|
|
59
|
+
".webm"
|
|
60
|
+
];
|
|
61
|
+
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
62
|
+
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
63
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
64
|
+
var OPENCV_BATCH_SIZE = 10;
|
|
65
|
+
var DBSCAN_ALPHA = 0.03;
|
|
66
|
+
var DBSCAN_MIN_PTS = 4;
|
|
67
|
+
var IOU_THRESHOLD = 0.9;
|
|
68
|
+
var DECAY_LAMBDA = 0.95;
|
|
69
|
+
var ANIMATION_FRAME_THRESHOLD = 5;
|
|
70
|
+
var MATCH_DISTANCE_THRESHOLD = 0.25;
|
|
71
|
+
var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
72
|
+
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
73
|
+
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
74
|
+
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
75
|
+
function getTempWorkspaceDir(sessionId) {
|
|
76
|
+
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/core/dbscan.ts
|
|
80
|
+
var UNVISITED = -2;
|
|
81
|
+
var NOISE = -1;
|
|
82
|
+
function dbscan(points, imageWidth, imageHeight, alpha, minPts) {
|
|
83
|
+
if (points.length === 0) {
|
|
84
|
+
return { labels: [], boundingBoxes: [] };
|
|
85
|
+
}
|
|
86
|
+
const eps = (alpha ?? DBSCAN_ALPHA) * Math.sqrt(imageWidth ** 2 + imageHeight ** 2);
|
|
87
|
+
const epsSquared = eps * eps;
|
|
88
|
+
const minPoints = minPts ?? DBSCAN_MIN_PTS;
|
|
89
|
+
const labels = new Array(points.length).fill(UNVISITED);
|
|
90
|
+
let clusterId = 0;
|
|
91
|
+
for (let i = 0; i < points.length; i++) {
|
|
92
|
+
if (labels[i] !== UNVISITED) continue;
|
|
93
|
+
const neighbors = findNeighbors(points, i, epsSquared);
|
|
94
|
+
if (neighbors.length < minPoints) {
|
|
95
|
+
labels[i] = NOISE;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
labels[i] = clusterId;
|
|
99
|
+
const seeds = [...neighbors];
|
|
100
|
+
const seedSet = new Set(seeds);
|
|
101
|
+
for (let si = 0; si < seeds.length; si++) {
|
|
102
|
+
const q = seeds[si];
|
|
103
|
+
if (labels[q] === NOISE) {
|
|
104
|
+
labels[q] = clusterId;
|
|
105
|
+
}
|
|
106
|
+
if (labels[q] !== UNVISITED) continue;
|
|
107
|
+
labels[q] = clusterId;
|
|
108
|
+
const qNeighbors = findNeighbors(points, q, epsSquared);
|
|
109
|
+
if (qNeighbors.length >= minPoints) {
|
|
110
|
+
for (const n of qNeighbors) {
|
|
111
|
+
if (!seedSet.has(n)) {
|
|
112
|
+
seedSet.add(n);
|
|
113
|
+
seeds.push(n);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
clusterId++;
|
|
119
|
+
}
|
|
120
|
+
const boundingBoxes = [];
|
|
121
|
+
for (let c = 0; c < clusterId; c++) {
|
|
122
|
+
let minX = Infinity;
|
|
123
|
+
let minY = Infinity;
|
|
124
|
+
let maxX = -Infinity;
|
|
125
|
+
let maxY = -Infinity;
|
|
126
|
+
for (let i = 0; i < points.length; i++) {
|
|
127
|
+
if (labels[i] !== c) continue;
|
|
128
|
+
const p = points[i];
|
|
129
|
+
if (p.x < minX) minX = p.x;
|
|
130
|
+
if (p.y < minY) minY = p.y;
|
|
131
|
+
if (p.x > maxX) maxX = p.x;
|
|
132
|
+
if (p.y > maxY) maxY = p.y;
|
|
133
|
+
}
|
|
134
|
+
boundingBoxes.push({
|
|
135
|
+
x: minX,
|
|
136
|
+
y: minY,
|
|
137
|
+
width: maxX - minX,
|
|
138
|
+
height: maxY - minY
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { labels, boundingBoxes };
|
|
142
|
+
}
|
|
143
|
+
function findNeighbors(points, idx, epsSquared) {
|
|
144
|
+
const p = points[idx];
|
|
145
|
+
const neighbors = [];
|
|
146
|
+
for (let i = 0; i < points.length; i++) {
|
|
147
|
+
if (i === idx) continue;
|
|
148
|
+
const q = points[i];
|
|
149
|
+
const distSq = (p.x - q.x) ** 2 + (p.y - q.y) ** 2;
|
|
150
|
+
if (distSq <= epsSquared) {
|
|
151
|
+
neighbors.push(i);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return neighbors;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/core/analyzer.ts
|
|
158
|
+
var OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
159
|
+
var require2 = createRequire(import.meta.url);
|
|
160
|
+
var cvReady = null;
|
|
161
|
+
async function ensureOpenCV() {
|
|
162
|
+
if (!cvReady) {
|
|
163
|
+
cvReady = (async () => {
|
|
164
|
+
const cvObj = require2("@techstark/opencv-js");
|
|
165
|
+
delete cvObj.then;
|
|
166
|
+
if (cvObj.Mat) return cvObj;
|
|
167
|
+
return new Promise((resolve2, reject) => {
|
|
168
|
+
const timeout = setTimeout(() => {
|
|
169
|
+
reject(new Error("OpenCV WASM initialization timed out after 30s"));
|
|
170
|
+
}, OPENCV_INIT_TIMEOUT_MS);
|
|
171
|
+
cvObj.onRuntimeInitialized = () => {
|
|
172
|
+
clearTimeout(timeout);
|
|
173
|
+
resolve2(cvObj);
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
})();
|
|
177
|
+
}
|
|
178
|
+
return cvReady;
|
|
179
|
+
}
|
|
180
|
+
async function preprocessFrame(framePath, scale) {
|
|
181
|
+
const { data, info } = await sharp(framePath).resize({ width: scale, withoutEnlargement: true }).grayscale().blur(1).raw().toBuffer({ resolveWithObject: true });
|
|
182
|
+
return {
|
|
183
|
+
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
184
|
+
width: info.width,
|
|
185
|
+
height: info.height
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function computeIoU(a, b) {
|
|
189
|
+
const ix1 = Math.max(a.x, b.x);
|
|
190
|
+
const iy1 = Math.max(a.y, b.y);
|
|
191
|
+
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
|
192
|
+
const iy2 = Math.min(a.y + a.height, b.y + b.height);
|
|
193
|
+
const iw = Math.max(0, ix2 - ix1);
|
|
194
|
+
const ih = Math.max(0, iy2 - iy1);
|
|
195
|
+
const intersection = iw * ih;
|
|
196
|
+
if (intersection === 0) return 0;
|
|
197
|
+
const aArea = a.width * a.height;
|
|
198
|
+
const bArea = b.width * b.height;
|
|
199
|
+
const union = aArea + bArea - intersection;
|
|
200
|
+
return union === 0 ? 0 : intersection / union;
|
|
201
|
+
}
|
|
202
|
+
var IoUTracker = class {
|
|
203
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
204
|
+
this.fps = fps;
|
|
205
|
+
this.iouThreshold = iouThreshold;
|
|
206
|
+
this.animationThreshold = animationThreshold;
|
|
207
|
+
}
|
|
208
|
+
regions = [];
|
|
209
|
+
extractedAnimations = [];
|
|
210
|
+
update(boxes, pairIndex) {
|
|
211
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
212
|
+
const matched = /* @__PURE__ */ new Set();
|
|
213
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
214
|
+
const box = boxes[bi];
|
|
215
|
+
let bestIoU = 0;
|
|
216
|
+
let bestRegionIdx = -1;
|
|
217
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
218
|
+
if (matched.has(ri)) continue;
|
|
219
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
220
|
+
if (iou > bestIoU) {
|
|
221
|
+
bestIoU = iou;
|
|
222
|
+
bestRegionIdx = ri;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
226
|
+
const region = this.regions[bestRegionIdx];
|
|
227
|
+
const gap = pairIndex - region.lastSeen;
|
|
228
|
+
region.box = box;
|
|
229
|
+
region.consecutiveCount++;
|
|
230
|
+
region.lastSeen = pairIndex;
|
|
231
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
232
|
+
matched.add(bestRegionIdx);
|
|
233
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
234
|
+
animationIndices.add(bi);
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
this.regions.push({
|
|
238
|
+
box,
|
|
239
|
+
consecutiveCount: 1,
|
|
240
|
+
firstSeen: pairIndex,
|
|
241
|
+
lastSeen: pairIndex,
|
|
242
|
+
weight: 1
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
247
|
+
if (!matched.has(ri)) {
|
|
248
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
249
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
253
|
+
const region = this.regions[i];
|
|
254
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
255
|
+
this.collectAnimation(region);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.regions = this.regions.filter(
|
|
259
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
260
|
+
);
|
|
261
|
+
return animationIndices;
|
|
262
|
+
}
|
|
263
|
+
collectAnimation(region) {
|
|
264
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
265
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
266
|
+
this.extractedAnimations.push({
|
|
267
|
+
type: "loading_spinner",
|
|
268
|
+
// 기본값으로 loading_spinner 사용
|
|
269
|
+
boundingBox: region.box,
|
|
270
|
+
startFrameId: region.firstSeen,
|
|
271
|
+
endFrameId: region.lastSeen,
|
|
272
|
+
durationMs
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
flushAndGetAnimations() {
|
|
277
|
+
for (const region of this.regions) {
|
|
278
|
+
this.collectAnimation(region);
|
|
279
|
+
}
|
|
280
|
+
this.regions = [];
|
|
281
|
+
return this.extractedAnimations;
|
|
282
|
+
}
|
|
283
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
284
|
+
if (boxIndex >= boxes.length) return 0;
|
|
285
|
+
const box = boxes[boxIndex];
|
|
286
|
+
let maxWeight = 0;
|
|
287
|
+
for (const region of this.regions) {
|
|
288
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
289
|
+
const iou = computeIoU(box, region.box);
|
|
290
|
+
if (iou > this.iouThreshold) {
|
|
291
|
+
maxWeight = Math.max(maxWeight, region.weight);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return maxWeight;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
299
|
+
const cv = cvLib;
|
|
300
|
+
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
301
|
+
mat1.data.set(frame1.data);
|
|
302
|
+
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
303
|
+
mat2.data.set(frame2.data);
|
|
304
|
+
const kp1 = new cvLib.KeyPointVector();
|
|
305
|
+
const kp2 = new cvLib.KeyPointVector();
|
|
306
|
+
const desc1 = new cvLib.Mat();
|
|
307
|
+
const desc2 = new cvLib.Mat();
|
|
308
|
+
const mask1 = new cvLib.Mat();
|
|
309
|
+
const mask2 = new cvLib.Mat();
|
|
310
|
+
const akaze = new cvLib.AKAZE();
|
|
311
|
+
let matches = null;
|
|
312
|
+
try {
|
|
313
|
+
akaze.detectAndCompute(mat1, mask1, kp1, desc1);
|
|
314
|
+
akaze.detectAndCompute(mat2, mask2, kp2, desc2);
|
|
315
|
+
const matchedKp1Indices = /* @__PURE__ */ new Set();
|
|
316
|
+
const matchedKp2Indices = /* @__PURE__ */ new Set();
|
|
317
|
+
if (desc1.rows > 0 && desc2.rows > 0) {
|
|
318
|
+
const matcher = new cvLib.BFMatcher(cvLib.NORM_HAMMING, false);
|
|
319
|
+
try {
|
|
320
|
+
matches = new cvLib.DMatchVectorVector();
|
|
321
|
+
matcher.knnMatch(desc1, desc2, matches, 2);
|
|
322
|
+
for (let i = 0; i < matches.size(); i++) {
|
|
323
|
+
const pair = matches.get(i);
|
|
324
|
+
if (pair.size() < 2) continue;
|
|
325
|
+
const m0 = pair.get(0);
|
|
326
|
+
const m1 = pair.get(1);
|
|
327
|
+
if (m0.distance < MATCH_DISTANCE_THRESHOLD * m1.distance) {
|
|
328
|
+
matchedKp1Indices.add(m0.queryIdx);
|
|
329
|
+
matchedKp2Indices.add(m0.trainIdx);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
matcher.delete();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const sNew = [];
|
|
337
|
+
for (let i = 0; i < kp2.size(); i++) {
|
|
338
|
+
if (!matchedKp2Indices.has(i)) {
|
|
339
|
+
const pt = kp2.get(i).pt;
|
|
340
|
+
sNew.push({ x: pt.x, y: pt.y });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const sLoss = [];
|
|
344
|
+
for (let i = 0; i < kp1.size(); i++) {
|
|
345
|
+
if (!matchedKp1Indices.has(i)) {
|
|
346
|
+
const pt = kp1.get(i).pt;
|
|
347
|
+
sLoss.push({ x: pt.x, y: pt.y });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { sNew, sLoss };
|
|
351
|
+
} finally {
|
|
352
|
+
mat1.delete();
|
|
353
|
+
mat2.delete();
|
|
354
|
+
kp1.delete();
|
|
355
|
+
kp2.delete();
|
|
356
|
+
desc1.delete();
|
|
357
|
+
desc2.delete();
|
|
358
|
+
mask1.delete();
|
|
359
|
+
mask2.delete();
|
|
360
|
+
akaze.delete();
|
|
361
|
+
if (matches) matches.delete();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function computePixelDiff(cvLib, frame1, frame2) {
|
|
365
|
+
const cv = cvLib;
|
|
366
|
+
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
367
|
+
const mat2 = new cv.Mat(frame2.height, frame2.width, cv.CV_8UC1);
|
|
368
|
+
const diff = new cv.Mat();
|
|
369
|
+
const blurred = new cv.Mat();
|
|
370
|
+
const binary = new cv.Mat();
|
|
371
|
+
const contours = new cv.MatVector();
|
|
372
|
+
const hierarchy = new cv.Mat();
|
|
373
|
+
try {
|
|
374
|
+
mat1.data.set(frame1.data);
|
|
375
|
+
mat2.data.set(frame2.data);
|
|
376
|
+
cv.absdiff(mat1, mat2, diff);
|
|
377
|
+
const ksize = new cv.Size(
|
|
378
|
+
PIXELDIFF_GAUSSIAN_KERNEL,
|
|
379
|
+
PIXELDIFF_GAUSSIAN_KERNEL
|
|
380
|
+
);
|
|
381
|
+
cv.GaussianBlur(diff, blurred, ksize, 0);
|
|
382
|
+
cv.threshold(
|
|
383
|
+
blurred,
|
|
384
|
+
binary,
|
|
385
|
+
PIXELDIFF_BINARY_THRESHOLD,
|
|
386
|
+
255,
|
|
387
|
+
cv.THRESH_BINARY
|
|
388
|
+
);
|
|
389
|
+
cv.findContours(
|
|
390
|
+
binary,
|
|
391
|
+
contours,
|
|
392
|
+
hierarchy,
|
|
393
|
+
cv.RETR_EXTERNAL,
|
|
394
|
+
cv.CHAIN_APPROX_SIMPLE
|
|
395
|
+
);
|
|
396
|
+
const points = [];
|
|
397
|
+
for (let c = 0; c < contours.size(); c++) {
|
|
398
|
+
const contour = contours.get(c);
|
|
399
|
+
const rect = cv.boundingRect(contour);
|
|
400
|
+
if (rect.width * rect.height < PIXELDIFF_CONTOUR_MIN_AREA) continue;
|
|
401
|
+
for (let y = rect.y; y < rect.y + rect.height; y += PIXELDIFF_SAMPLE_SPACING) {
|
|
402
|
+
for (let x = rect.x; x < rect.x + rect.width; x += PIXELDIFF_SAMPLE_SPACING) {
|
|
403
|
+
points.push({ x, y });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return points;
|
|
408
|
+
} finally {
|
|
409
|
+
mat1.delete();
|
|
410
|
+
mat2.delete();
|
|
411
|
+
diff.delete();
|
|
412
|
+
blurred.delete();
|
|
413
|
+
binary.delete();
|
|
414
|
+
contours.delete();
|
|
415
|
+
hierarchy.delete();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function computeInformationGain(clusters, clusterPoints, imageArea, animationIndices, animationWeights) {
|
|
419
|
+
if (clusters.length === 0) return 0;
|
|
420
|
+
let gain = 0;
|
|
421
|
+
for (let i = 0; i < clusters.length; i++) {
|
|
422
|
+
const box = clusters[i];
|
|
423
|
+
const clusterArea = box.width * box.height;
|
|
424
|
+
if (clusterArea <= 0) continue;
|
|
425
|
+
const normalizedArea = clusterArea / imageArea;
|
|
426
|
+
const featureDensity = clusterPoints[i] / clusterArea;
|
|
427
|
+
let contribution = normalizedArea * featureDensity;
|
|
428
|
+
if (animationIndices.has(i)) {
|
|
429
|
+
const animWeight = animationWeights[i] ?? 0;
|
|
430
|
+
contribution *= 1 - animWeight;
|
|
431
|
+
}
|
|
432
|
+
gain += contribution;
|
|
433
|
+
}
|
|
434
|
+
return gain;
|
|
435
|
+
}
|
|
436
|
+
async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
437
|
+
const edges = [];
|
|
438
|
+
const preprocessed = await Promise.all(
|
|
439
|
+
frames.map((f) => preprocessFrame(f.extractPath, scale))
|
|
440
|
+
);
|
|
441
|
+
const imageWidth = preprocessed[0]?.width ?? scale;
|
|
442
|
+
const imageHeight = preprocessed[0]?.height ?? Math.round(scale * 9 / 16);
|
|
443
|
+
const imageArea = imageWidth * imageHeight;
|
|
444
|
+
for (let i = 0; i < frames.length - 1; i++) {
|
|
445
|
+
const pairIndex = pairOffset + i;
|
|
446
|
+
try {
|
|
447
|
+
const { sNew } = await computeAKAZEDiff(
|
|
448
|
+
cvLib,
|
|
449
|
+
preprocessed[i],
|
|
450
|
+
preprocessed[i + 1]
|
|
451
|
+
);
|
|
452
|
+
let dbscanResult = dbscan(sNew, imageWidth, imageHeight);
|
|
453
|
+
let clusters = dbscanResult.boundingBoxes;
|
|
454
|
+
if (clusters.length === 0) {
|
|
455
|
+
const pixelDiffPoints = computePixelDiff(
|
|
456
|
+
cvLib,
|
|
457
|
+
preprocessed[i],
|
|
458
|
+
preprocessed[i + 1]
|
|
459
|
+
);
|
|
460
|
+
if (pixelDiffPoints.length > 0) {
|
|
461
|
+
logger.debug(
|
|
462
|
+
`Edge ${frames[i].id}->${frames[i + 1].id}: pixel-diff fallback (${pixelDiffPoints.length} points)`
|
|
463
|
+
);
|
|
464
|
+
dbscanResult = dbscan(
|
|
465
|
+
pixelDiffPoints,
|
|
466
|
+
imageWidth,
|
|
467
|
+
imageHeight,
|
|
468
|
+
void 0,
|
|
469
|
+
2
|
|
470
|
+
);
|
|
471
|
+
clusters = dbscanResult.boundingBoxes;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const clusterPointCounts = new Array(clusters.length).fill(0);
|
|
475
|
+
for (const label of dbscanResult.labels) {
|
|
476
|
+
if (label >= 0) {
|
|
477
|
+
clusterPointCounts[label]++;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const animationIndices = tracker.update(clusters, pairIndex);
|
|
481
|
+
const animationWeights = clusters.map(
|
|
482
|
+
(_, ci) => animationIndices.has(ci) ? tracker.getAnimationWeight(ci, clusters) : 0
|
|
483
|
+
);
|
|
484
|
+
const score = computeInformationGain(
|
|
485
|
+
clusters,
|
|
486
|
+
clusterPointCounts,
|
|
487
|
+
imageArea,
|
|
488
|
+
animationIndices,
|
|
489
|
+
animationWeights
|
|
490
|
+
);
|
|
491
|
+
logger.debug(
|
|
492
|
+
`Edge ${frames[i].id}->${frames[i + 1].id} G(t)=${score.toFixed(6)}`
|
|
493
|
+
);
|
|
494
|
+
edges.push({
|
|
495
|
+
sourceId: frames[i].id,
|
|
496
|
+
targetId: frames[i + 1].id,
|
|
497
|
+
score
|
|
498
|
+
});
|
|
499
|
+
} catch (err) {
|
|
500
|
+
logger.debug(`Frame pair analysis failed: ${String(err)}`);
|
|
501
|
+
edges.push({
|
|
502
|
+
sourceId: frames[i].id,
|
|
503
|
+
targetId: frames[i + 1].id,
|
|
504
|
+
score: 0
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return edges;
|
|
509
|
+
}
|
|
510
|
+
async function analyzeFrames(ctx) {
|
|
511
|
+
const { frames } = ctx;
|
|
512
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
513
|
+
logger.debug(
|
|
514
|
+
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
515
|
+
);
|
|
516
|
+
const cvLib = await ensureOpenCV();
|
|
517
|
+
const edges = [];
|
|
518
|
+
const tracker = new IoUTracker(
|
|
519
|
+
ctx.options.fps,
|
|
520
|
+
ctx.options.iouThreshold,
|
|
521
|
+
ctx.options.animationThreshold
|
|
522
|
+
);
|
|
523
|
+
const scale = ctx.options.scale;
|
|
524
|
+
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
525
|
+
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
526
|
+
const batch = frames.slice(i, batchEnd);
|
|
527
|
+
const batchEdges = await analyzeBatch(cvLib, batch, scale, tracker, i);
|
|
528
|
+
edges.push(...batchEdges);
|
|
529
|
+
const progress = Math.min(
|
|
530
|
+
100,
|
|
531
|
+
(i + OPENCV_BATCH_SIZE) / (frames.length - 1) * 100
|
|
532
|
+
);
|
|
533
|
+
ctx.emitProgress(progress);
|
|
534
|
+
}
|
|
535
|
+
const animations = tracker.flushAndGetAnimations();
|
|
536
|
+
logger.debug(
|
|
537
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
538
|
+
);
|
|
539
|
+
return { edges, animations };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/core/extractor.ts
|
|
543
|
+
import { readdir } from "fs/promises";
|
|
544
|
+
import { join as join2 } from "path";
|
|
545
|
+
import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
|
|
546
|
+
import { execa } from "execa";
|
|
547
|
+
import ffmpegPath from "ffmpeg-static";
|
|
548
|
+
|
|
549
|
+
// src/utils/paths.ts
|
|
550
|
+
import { mkdir, stat } from "fs/promises";
|
|
551
|
+
import { homedir } from "os";
|
|
552
|
+
import { basename, extname, resolve } from "path";
|
|
553
|
+
async function ensureDir(dirPath) {
|
|
554
|
+
await mkdir(dirPath, { recursive: true });
|
|
555
|
+
}
|
|
556
|
+
async function fileExists(filePath) {
|
|
557
|
+
try {
|
|
558
|
+
await stat(filePath);
|
|
559
|
+
return true;
|
|
560
|
+
} catch {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function expandTilde(p) {
|
|
565
|
+
if (p === "~") return homedir();
|
|
566
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
567
|
+
return resolve(homedir(), p.slice(2));
|
|
568
|
+
}
|
|
569
|
+
return p;
|
|
570
|
+
}
|
|
571
|
+
function resolveAbsolute(p) {
|
|
572
|
+
return resolve(expandTilde(p));
|
|
573
|
+
}
|
|
574
|
+
function deriveOutputPath(inputPath) {
|
|
575
|
+
const dir = resolve(inputPath, "..");
|
|
576
|
+
const name = basename(inputPath, extname(inputPath));
|
|
577
|
+
return resolve(dir, `${name}_scenes`);
|
|
578
|
+
}
|
|
579
|
+
function isSupportedFile(filePath, extensions) {
|
|
580
|
+
return extensions.includes(extname(filePath).toLowerCase());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/core/extractor.ts
|
|
584
|
+
async function extractFrames(ctx) {
|
|
585
|
+
const framesDir = join2(ctx.workspacePath, "frames");
|
|
586
|
+
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
587
|
+
if (!inputPath) {
|
|
588
|
+
throw new Error("inputPath is required for frame extraction");
|
|
589
|
+
}
|
|
590
|
+
const exists = await fileExists(inputPath);
|
|
591
|
+
if (!exists) {
|
|
592
|
+
throw new Error(`Input file not found: ${inputPath}`);
|
|
593
|
+
}
|
|
594
|
+
const allExtensions = [
|
|
595
|
+
...SUPPORTED_VIDEO_EXTENSIONS,
|
|
596
|
+
...SUPPORTED_GIF_EXTENSIONS
|
|
597
|
+
];
|
|
598
|
+
if (!isSupportedFile(inputPath, allExtensions)) {
|
|
599
|
+
throw new Error(`Unsupported file format: ${inputPath}`);
|
|
600
|
+
}
|
|
601
|
+
logger.debug(`Extracting frames from: ${inputPath}`);
|
|
602
|
+
await ensureDir(framesDir);
|
|
603
|
+
let effectiveFps = fps;
|
|
604
|
+
const duration = await getVideoDuration(inputPath).catch(() => 0);
|
|
605
|
+
if (duration > 0) {
|
|
606
|
+
const fpsCap = maxFrames / duration;
|
|
607
|
+
effectiveFps = Math.min(fps, fpsCap);
|
|
608
|
+
effectiveFps = Math.max(0.5, effectiveFps);
|
|
609
|
+
logger.debug(
|
|
610
|
+
`Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
|
|
614
|
+
ctx.emitProgress(100);
|
|
615
|
+
logger.debug(`Extracted ${frames.length} frames`);
|
|
616
|
+
return frames;
|
|
617
|
+
}
|
|
618
|
+
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
619
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
620
|
+
await execa(ffmpegPath, [
|
|
621
|
+
"-i",
|
|
622
|
+
inputPath,
|
|
623
|
+
"-vf",
|
|
624
|
+
`fps=${fps},scale=-1:${scale}`,
|
|
625
|
+
"-q:v",
|
|
626
|
+
"2",
|
|
627
|
+
outputPattern
|
|
628
|
+
]);
|
|
629
|
+
return buildFrameList(outputDir, inputPath);
|
|
630
|
+
}
|
|
631
|
+
async function getVideoDuration(inputPath) {
|
|
632
|
+
const { stdout } = await execa(ffprobePath, [
|
|
633
|
+
"-v",
|
|
634
|
+
"quiet",
|
|
635
|
+
"-print_format",
|
|
636
|
+
"json",
|
|
637
|
+
"-show_format",
|
|
638
|
+
inputPath
|
|
639
|
+
]);
|
|
640
|
+
const metadata = JSON.parse(stdout);
|
|
641
|
+
return parseFloat(metadata.format?.duration ?? "0");
|
|
642
|
+
}
|
|
643
|
+
async function buildFrameList(framesDir, inputPath) {
|
|
644
|
+
const files = await readdir(framesDir);
|
|
645
|
+
const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
|
|
646
|
+
if (jpgFiles.length === 0) {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
let duration = 0;
|
|
650
|
+
try {
|
|
651
|
+
duration = await getVideoDuration(inputPath);
|
|
652
|
+
} catch {
|
|
653
|
+
logger.debug(
|
|
654
|
+
"Could not determine video duration; using frame index for timestamps"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return jpgFiles.map((file, index) => ({
|
|
658
|
+
id: index,
|
|
659
|
+
timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
|
|
660
|
+
extractPath: join2(framesDir, file)
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/core/input-resolver.ts
|
|
665
|
+
import { join as join4 } from "path";
|
|
666
|
+
|
|
667
|
+
// src/core/workspace.ts
|
|
668
|
+
import { readdir as readdir2, rename, rm, stat as stat2, writeFile } from "fs/promises";
|
|
669
|
+
import { join as join3 } from "path";
|
|
670
|
+
import sharp2 from "sharp";
|
|
671
|
+
async function createWorkspace(sessionId) {
|
|
672
|
+
const workspacePath = getTempWorkspaceDir(sessionId);
|
|
673
|
+
await ensureDir(join3(workspacePath, "frames"));
|
|
674
|
+
await ensureDir(join3(workspacePath, "output"));
|
|
675
|
+
return workspacePath;
|
|
676
|
+
}
|
|
677
|
+
async function finalizeOutput(ctx, selectedFrames) {
|
|
678
|
+
const stagingDir = join3(ctx.workspacePath, "output");
|
|
679
|
+
const outputPath = ctx.options.outputPath;
|
|
680
|
+
const quality = ctx.options.quality;
|
|
681
|
+
const outputFiles = [];
|
|
682
|
+
const framesMetadata = [];
|
|
683
|
+
const totalFramesCount = ctx.frames.length;
|
|
684
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
685
|
+
for (let i = 0; i < selectedFrames.length; i++) {
|
|
686
|
+
const frame = selectedFrames[i];
|
|
687
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
688
|
+
const destPath = join3(stagingDir, fileName);
|
|
689
|
+
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
690
|
+
outputFiles.push(join3(outputPath, fileName));
|
|
691
|
+
framesMetadata.push({
|
|
692
|
+
step: i + 1,
|
|
693
|
+
fileName,
|
|
694
|
+
frameId: frame.id + 1,
|
|
695
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
const metadata = {
|
|
699
|
+
video: {
|
|
700
|
+
originalDurationMs: Math.round(
|
|
701
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
702
|
+
),
|
|
703
|
+
fps: ctx.options.fps,
|
|
704
|
+
resolution: {
|
|
705
|
+
width: ctx.options.scale,
|
|
706
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
frames: framesMetadata,
|
|
710
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
711
|
+
...anim,
|
|
712
|
+
startFrameId: anim.startFrameId + 1,
|
|
713
|
+
endFrameId: anim.endFrameId + 1,
|
|
714
|
+
durationMs: Math.round(anim.durationMs)
|
|
715
|
+
}))
|
|
716
|
+
};
|
|
717
|
+
const metadataPath = join3(stagingDir, ".metadata.json");
|
|
718
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
719
|
+
outputFiles.push(join3(outputPath, ".metadata.json"));
|
|
720
|
+
await ensureDir(join3(outputPath, ".."));
|
|
721
|
+
await rm(outputPath, { recursive: true, force: true });
|
|
722
|
+
await rename(stagingDir, outputPath);
|
|
723
|
+
return outputFiles;
|
|
724
|
+
}
|
|
725
|
+
async function cleanupWorkspace(workspacePath) {
|
|
726
|
+
if (!workspacePath) return;
|
|
727
|
+
try {
|
|
728
|
+
await rm(workspacePath, { recursive: true, force: true });
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
var STALE_THRESHOLD_MS = 60 * 60 * 1e3;
|
|
733
|
+
async function writeInputBuffer(buffer, workspacePath) {
|
|
734
|
+
const inputDir = join3(workspacePath, "input");
|
|
735
|
+
await ensureDir(inputDir);
|
|
736
|
+
const tempPath = join3(inputDir, "input.mp4");
|
|
737
|
+
await writeFile(tempPath, buffer);
|
|
738
|
+
return tempPath;
|
|
739
|
+
}
|
|
740
|
+
async function writeInputFrames(frames, workspacePath) {
|
|
741
|
+
const framesDir = join3(workspacePath, "frames");
|
|
742
|
+
await ensureDir(framesDir);
|
|
743
|
+
const frameNodes = [];
|
|
744
|
+
for (let i = 0; i < frames.length; i++) {
|
|
745
|
+
const filename = `frame_${String(i).padStart(6, "0")}${FRAME_OUTPUT_EXTENSION}`;
|
|
746
|
+
const extractPath = join3(framesDir, filename);
|
|
747
|
+
await writeFile(extractPath, frames[i]);
|
|
748
|
+
frameNodes.push({ id: i, timestamp: i, extractPath });
|
|
749
|
+
}
|
|
750
|
+
return frameNodes;
|
|
751
|
+
}
|
|
752
|
+
async function readFramesAsBuffers(frameNodes, quality) {
|
|
753
|
+
return Promise.all(
|
|
754
|
+
frameNodes.map(
|
|
755
|
+
(f) => sharp2(f.extractPath).jpeg({ quality, mozjpeg: true }).toBuffer()
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/core/input-resolver.ts
|
|
761
|
+
function resolveOptions(options2) {
|
|
762
|
+
const mode = options2.mode;
|
|
763
|
+
const inputPath = mode === "file" ? resolveAbsolute(options2.inputPath) : void 0;
|
|
764
|
+
const outputPath = options2.outputPath ?? (inputPath ? deriveOutputPath(inputPath) : join4(process.cwd(), "scene-sieve-output"));
|
|
765
|
+
const threshold = options2.threshold ?? DEFAULT_THRESHOLD;
|
|
766
|
+
if (threshold <= 0 || threshold > 1) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`threshold must be in range (0, 1], received: ${threshold}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const pruneMode = "threshold-with-cap";
|
|
772
|
+
return {
|
|
773
|
+
mode,
|
|
774
|
+
inputPath,
|
|
775
|
+
count: options2.count ?? DEFAULT_COUNT,
|
|
776
|
+
threshold,
|
|
777
|
+
pruneMode,
|
|
778
|
+
outputPath,
|
|
779
|
+
fps: options2.fps ?? DEFAULT_FPS,
|
|
780
|
+
maxFrames: options2.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
781
|
+
scale: options2.scale ?? DEFAULT_SCALE,
|
|
782
|
+
quality: options2.quality ?? DEFAULT_QUALITY,
|
|
783
|
+
iouThreshold: options2.iouThreshold ?? IOU_THRESHOLD,
|
|
784
|
+
animationThreshold: options2.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
785
|
+
debug: options2.debug ?? false
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
async function resolveInput(options2, workspacePath) {
|
|
789
|
+
if (options2.mode === "file") {
|
|
790
|
+
return {
|
|
791
|
+
frames: [],
|
|
792
|
+
resolvedInputPath: resolveAbsolute(options2.inputPath)
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (options2.mode === "buffer") {
|
|
796
|
+
const resolvedInputPath = await writeInputBuffer(
|
|
797
|
+
options2.inputBuffer,
|
|
798
|
+
workspacePath
|
|
799
|
+
);
|
|
800
|
+
return { frames: [], resolvedInputPath };
|
|
801
|
+
}
|
|
802
|
+
if (options2.mode === "frames") {
|
|
803
|
+
const frames = await writeInputFrames(options2.inputFrames, workspacePath);
|
|
804
|
+
return { frames };
|
|
805
|
+
}
|
|
806
|
+
throw new Error(`Unsupported input mode: ${options2.mode}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/utils/min-heap.ts
|
|
810
|
+
var MinHeap = class {
|
|
811
|
+
h = [];
|
|
812
|
+
get size() {
|
|
813
|
+
return this.h.length;
|
|
814
|
+
}
|
|
815
|
+
push(entry) {
|
|
816
|
+
this.h.push(entry);
|
|
817
|
+
this.siftUp(this.h.length - 1);
|
|
818
|
+
}
|
|
819
|
+
pop() {
|
|
820
|
+
const n = this.h.length;
|
|
821
|
+
if (n === 0) return void 0;
|
|
822
|
+
const top = this.h[0];
|
|
823
|
+
const last = this.h.pop();
|
|
824
|
+
if (n > 1) {
|
|
825
|
+
this.h[0] = last;
|
|
826
|
+
this.siftDown(0);
|
|
827
|
+
}
|
|
828
|
+
return top;
|
|
829
|
+
}
|
|
830
|
+
siftUp(i) {
|
|
831
|
+
while (i > 0) {
|
|
832
|
+
const p = i - 1 >> 1;
|
|
833
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
834
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
835
|
+
i = p;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
siftDown(i) {
|
|
839
|
+
const n = this.h.length;
|
|
840
|
+
for (; ; ) {
|
|
841
|
+
let m = i;
|
|
842
|
+
const l = 2 * i + 1;
|
|
843
|
+
const r = 2 * i + 2;
|
|
844
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
845
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
846
|
+
if (m === i) break;
|
|
847
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
848
|
+
i = m;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/core/pruner.ts
|
|
854
|
+
function pruneTo(graph, frames, targetCount) {
|
|
855
|
+
if (frames.length <= targetCount) {
|
|
856
|
+
return new Set(frames.map((f) => f.id));
|
|
857
|
+
}
|
|
858
|
+
const prev = /* @__PURE__ */ new Map();
|
|
859
|
+
const next = /* @__PURE__ */ new Map();
|
|
860
|
+
for (let i = 0; i < frames.length; i++) {
|
|
861
|
+
if (i > 0) prev.set(frames[i].id, frames[i - 1].id);
|
|
862
|
+
if (i < frames.length - 1) next.set(frames[i].id, frames[i + 1].id);
|
|
863
|
+
}
|
|
864
|
+
const edgeScore = /* @__PURE__ */ new Map();
|
|
865
|
+
const heap = new MinHeap();
|
|
866
|
+
for (const edge of graph) {
|
|
867
|
+
edgeScore.set(`${edge.sourceId}:${edge.targetId}`, edge.score);
|
|
868
|
+
heap.push({
|
|
869
|
+
score: edge.score,
|
|
870
|
+
srcId: edge.sourceId,
|
|
871
|
+
tgtId: edge.targetId
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const surviving = new Set(frames.map((f) => f.id));
|
|
875
|
+
const firstId = frames[0].id;
|
|
876
|
+
const lastId = frames[frames.length - 1].id;
|
|
877
|
+
while (surviving.size > targetCount && heap.size > 0) {
|
|
878
|
+
const entry = heap.pop();
|
|
879
|
+
if (!surviving.has(entry.srcId) || !surviving.has(entry.tgtId)) continue;
|
|
880
|
+
const key = `${entry.srcId}:${entry.tgtId}`;
|
|
881
|
+
if (edgeScore.get(key) !== entry.score) continue;
|
|
882
|
+
if (entry.tgtId === firstId || entry.tgtId === lastId) continue;
|
|
883
|
+
surviving.delete(entry.tgtId);
|
|
884
|
+
edgeScore.delete(key);
|
|
885
|
+
const tgtNext = next.get(entry.tgtId);
|
|
886
|
+
if (tgtNext !== void 0) {
|
|
887
|
+
const rightKey = `${entry.tgtId}:${tgtNext}`;
|
|
888
|
+
const rightScore = edgeScore.get(rightKey) ?? 0;
|
|
889
|
+
edgeScore.delete(rightKey);
|
|
890
|
+
const newScore = Math.max(entry.score, rightScore);
|
|
891
|
+
edgeScore.set(`${entry.srcId}:${tgtNext}`, newScore);
|
|
892
|
+
heap.push({ score: newScore, srcId: entry.srcId, tgtId: tgtNext });
|
|
893
|
+
next.set(entry.srcId, tgtNext);
|
|
894
|
+
prev.set(tgtNext, entry.srcId);
|
|
895
|
+
} else {
|
|
896
|
+
next.delete(entry.srcId);
|
|
897
|
+
}
|
|
898
|
+
prev.delete(entry.tgtId);
|
|
899
|
+
next.delete(entry.tgtId);
|
|
900
|
+
}
|
|
901
|
+
return surviving;
|
|
902
|
+
}
|
|
903
|
+
function normalizeScores(graph) {
|
|
904
|
+
if (graph.length === 0) return [];
|
|
905
|
+
const safeScores = graph.map(
|
|
906
|
+
(e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
|
|
907
|
+
);
|
|
908
|
+
const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
|
|
909
|
+
if (sorted.length === 0) return safeScores;
|
|
910
|
+
const pIdx = Math.min(
|
|
911
|
+
Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
|
|
912
|
+
sorted.length - 1
|
|
913
|
+
);
|
|
914
|
+
const refScore = sorted[pIdx];
|
|
915
|
+
return safeScores.map((s) => Math.min(s / refScore, 1));
|
|
916
|
+
}
|
|
917
|
+
function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
|
|
918
|
+
const result = /* @__PURE__ */ new Set();
|
|
919
|
+
let runStart = 0;
|
|
920
|
+
while (runStart < passingIndices.length) {
|
|
921
|
+
let runEnd = runStart;
|
|
922
|
+
while (runEnd + 1 < passingIndices.length && passingIndices[runEnd + 1] === passingIndices[runEnd] + 1) {
|
|
923
|
+
runEnd++;
|
|
924
|
+
}
|
|
925
|
+
const runLen = runEnd - runStart + 1;
|
|
926
|
+
if (runLen === 1) {
|
|
927
|
+
result.add(graph[passingIndices[runStart]].targetId);
|
|
928
|
+
} else {
|
|
929
|
+
const peaks = [];
|
|
930
|
+
for (let j = runStart; j <= runEnd; j++) {
|
|
931
|
+
const idx = passingIndices[j];
|
|
932
|
+
const score = normalizedScores[idx];
|
|
933
|
+
const prevScore = j > runStart ? normalizedScores[passingIndices[j - 1]] : -Infinity;
|
|
934
|
+
const nextScore = j < runEnd ? normalizedScores[passingIndices[j + 1]] : -Infinity;
|
|
935
|
+
if (score > prevScore && score > nextScore) {
|
|
936
|
+
peaks.push(idx);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (peaks.length > 0) {
|
|
940
|
+
for (const peakIdx of peaks) {
|
|
941
|
+
result.add(graph[peakIdx].targetId);
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
let peakIdx = passingIndices[runStart];
|
|
945
|
+
for (let j = runStart + 1; j <= runEnd; j++) {
|
|
946
|
+
const idx = passingIndices[j];
|
|
947
|
+
if (normalizedScores[idx] > normalizedScores[peakIdx]) {
|
|
948
|
+
peakIdx = idx;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
result.add(graph[peakIdx].targetId);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
runStart = runEnd + 1;
|
|
955
|
+
}
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
function pruneByThreshold(graph, frames, threshold) {
|
|
959
|
+
if (frames.length === 0) return /* @__PURE__ */ new Set();
|
|
960
|
+
const surviving = /* @__PURE__ */ new Set();
|
|
961
|
+
surviving.add(frames[0].id);
|
|
962
|
+
surviving.add(frames[frames.length - 1].id);
|
|
963
|
+
const normalized = normalizeScores(graph);
|
|
964
|
+
const passingIndices = [];
|
|
965
|
+
for (let i = 0; i < graph.length; i++) {
|
|
966
|
+
if (normalized[i] >= threshold) {
|
|
967
|
+
passingIndices.push(i);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const nmsTargets = suppressConsecutiveRuns(graph, passingIndices, normalized);
|
|
971
|
+
for (const id of nmsTargets) {
|
|
972
|
+
surviving.add(id);
|
|
973
|
+
}
|
|
974
|
+
return surviving;
|
|
975
|
+
}
|
|
976
|
+
function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
977
|
+
const thresholdSurvivors = pruneByThreshold(graph, frames, threshold);
|
|
978
|
+
if (thresholdSurvivors.size <= maxCount) {
|
|
979
|
+
return thresholdSurvivors;
|
|
980
|
+
}
|
|
981
|
+
const survivingFrames = frames.filter((f) => thresholdSurvivors.has(f.id));
|
|
982
|
+
const idToOrigIdx = /* @__PURE__ */ new Map();
|
|
983
|
+
for (let i = 0; i < frames.length; i++) {
|
|
984
|
+
idToOrigIdx.set(frames[i].id, i);
|
|
985
|
+
}
|
|
986
|
+
const edgeLookup = /* @__PURE__ */ new Map();
|
|
987
|
+
for (const e of graph) {
|
|
988
|
+
edgeLookup.set(`${e.sourceId}:${e.targetId}`, e.score);
|
|
989
|
+
}
|
|
990
|
+
const syntheticEdges = [];
|
|
991
|
+
for (let i = 0; i < survivingFrames.length - 1; i++) {
|
|
992
|
+
const srcSurvivor = survivingFrames[i];
|
|
993
|
+
const tgtSurvivor = survivingFrames[i + 1];
|
|
994
|
+
const srcOrigIdx = idToOrigIdx.get(srcSurvivor.id);
|
|
995
|
+
const tgtOrigIdx = idToOrigIdx.get(tgtSurvivor.id);
|
|
996
|
+
let minScore = Infinity;
|
|
997
|
+
for (let j = srcOrigIdx; j < tgtOrigIdx; j++) {
|
|
998
|
+
const fromId = frames[j].id;
|
|
999
|
+
const toId = frames[j + 1].id;
|
|
1000
|
+
const score = edgeLookup.get(`${fromId}:${toId}`) ?? 0;
|
|
1001
|
+
if (score < minScore) {
|
|
1002
|
+
minScore = score;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
syntheticEdges.push({
|
|
1006
|
+
sourceId: srcSurvivor.id,
|
|
1007
|
+
targetId: tgtSurvivor.id,
|
|
1008
|
+
score: minScore === Infinity ? 0 : minScore
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/core/orchestrator.ts
|
|
1015
|
+
async function runPipeline(options2) {
|
|
1016
|
+
const startTime = Date.now();
|
|
1017
|
+
const sessionId = randomUUID();
|
|
1018
|
+
const debug = options2.debug ?? false;
|
|
1019
|
+
if (debug) setDebugMode(true);
|
|
1020
|
+
const resolvedOptions = resolveOptions(options2);
|
|
1021
|
+
const ctx = {
|
|
1022
|
+
options: resolvedOptions,
|
|
1023
|
+
workspacePath: "",
|
|
1024
|
+
frames: [],
|
|
1025
|
+
graph: [],
|
|
1026
|
+
status: "INIT",
|
|
1027
|
+
emitProgress: (percent) => {
|
|
1028
|
+
if (options2.onProgress && ctx.status !== "INIT" && ctx.status !== "SUCCESS" && ctx.status !== "FAILED") {
|
|
1029
|
+
options2.onProgress(ctx.status, percent);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
try {
|
|
1034
|
+
ctx.workspacePath = await createWorkspace(sessionId);
|
|
1035
|
+
logger.debug(`Workspace created: ${ctx.workspacePath}`);
|
|
1036
|
+
ctx.status = "EXTRACTING";
|
|
1037
|
+
const { frames: resolvedFrames, resolvedInputPath } = await resolveInput(
|
|
1038
|
+
options2,
|
|
1039
|
+
ctx.workspacePath
|
|
1040
|
+
);
|
|
1041
|
+
if (resolvedOptions.mode === "frames") {
|
|
1042
|
+
ctx.frames = resolvedFrames;
|
|
1043
|
+
} else {
|
|
1044
|
+
const extractCtx = {
|
|
1045
|
+
...ctx,
|
|
1046
|
+
options: {
|
|
1047
|
+
...resolvedOptions,
|
|
1048
|
+
inputPath: resolvedInputPath
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
ctx.frames = await extractFrames(extractCtx);
|
|
1052
|
+
}
|
|
1053
|
+
ctx.emitProgress(100);
|
|
1054
|
+
ctx.status = "ANALYZING";
|
|
1055
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1056
|
+
ctx.graph = edges;
|
|
1057
|
+
ctx.animations = animations;
|
|
1058
|
+
ctx.status = "PRUNING";
|
|
1059
|
+
const survivingIds = pruneByThresholdWithCap(
|
|
1060
|
+
ctx.graph,
|
|
1061
|
+
ctx.frames,
|
|
1062
|
+
resolvedOptions.threshold,
|
|
1063
|
+
resolvedOptions.count
|
|
1064
|
+
);
|
|
1065
|
+
const prunedFrames = ctx.frames.filter((f) => survivingIds.has(f.id));
|
|
1066
|
+
ctx.emitProgress(100);
|
|
1067
|
+
ctx.status = "FINALIZING";
|
|
1068
|
+
let outputFiles = [];
|
|
1069
|
+
let outputBuffers;
|
|
1070
|
+
if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
|
|
1071
|
+
outputBuffers = await readFramesAsBuffers(
|
|
1072
|
+
prunedFrames,
|
|
1073
|
+
resolvedOptions.quality
|
|
1074
|
+
);
|
|
1075
|
+
ctx.emitProgress(100);
|
|
1076
|
+
} else {
|
|
1077
|
+
outputFiles = await finalizeOutput(ctx, prunedFrames);
|
|
1078
|
+
ctx.emitProgress(100);
|
|
1079
|
+
}
|
|
1080
|
+
ctx.status = "SUCCESS";
|
|
1081
|
+
logger.success(
|
|
1082
|
+
`Extracted ${prunedFrames.length} scenes from ${ctx.frames.length} frames`
|
|
1083
|
+
);
|
|
1084
|
+
return {
|
|
1085
|
+
success: true,
|
|
1086
|
+
originalFramesCount: ctx.frames.length,
|
|
1087
|
+
prunedFramesCount: prunedFrames.length,
|
|
1088
|
+
outputFiles,
|
|
1089
|
+
outputBuffers,
|
|
1090
|
+
animations: ctx.animations,
|
|
1091
|
+
video: {
|
|
1092
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1093
|
+
fps: ctx.options.fps,
|
|
1094
|
+
resolution: {
|
|
1095
|
+
width: ctx.options.scale,
|
|
1096
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
executionTimeMs: Date.now() - startTime
|
|
1100
|
+
};
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
ctx.status = "FAILED";
|
|
1103
|
+
ctx.error = error instanceof Error ? error : new Error(String(error));
|
|
1104
|
+
logger.error(`Pipeline failed: ${ctx.error.message}`);
|
|
1105
|
+
throw ctx.error;
|
|
1106
|
+
} finally {
|
|
1107
|
+
if (!resolvedOptions.debug) {
|
|
1108
|
+
await cleanupWorkspace(ctx.workspacePath);
|
|
1109
|
+
} else {
|
|
1110
|
+
logger.debug(`Debug mode: workspace preserved at ${ctx.workspacePath}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/core/pipeline-worker.ts
|
|
1116
|
+
var options = workerData;
|
|
1117
|
+
runPipeline({
|
|
1118
|
+
...options,
|
|
1119
|
+
onProgress: (phase, percent) => {
|
|
1120
|
+
parentPort?.postMessage({
|
|
1121
|
+
type: "progress",
|
|
1122
|
+
phase,
|
|
1123
|
+
percent
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}).then((result) => {
|
|
1127
|
+
parentPort?.postMessage({
|
|
1128
|
+
type: "result",
|
|
1129
|
+
result
|
|
1130
|
+
});
|
|
1131
|
+
}).catch((error) => {
|
|
1132
|
+
parentPort?.postMessage({
|
|
1133
|
+
type: "error",
|
|
1134
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1135
|
+
});
|
|
1136
|
+
});
|