@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
package/dist/cli.mjs
CHANGED
|
@@ -1,86 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
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;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
9
11
|
|
|
10
12
|
// src/utils/logger.ts
|
|
11
13
|
import pc from "picocolors";
|
|
12
|
-
var debugMode = false;
|
|
13
14
|
function setDebugMode(enabled) {
|
|
14
15
|
debugMode = enabled;
|
|
15
16
|
}
|
|
16
17
|
function timestamp() {
|
|
17
18
|
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
18
19
|
}
|
|
19
|
-
var logger
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
var debugMode, logger;
|
|
21
|
+
var init_logger = __esm({
|
|
22
|
+
"src/utils/logger.ts"() {
|
|
23
|
+
"use strict";
|
|
24
|
+
debugMode = false;
|
|
25
|
+
logger = {
|
|
26
|
+
info(message) {
|
|
27
|
+
console.log(`${pc.blue("info")} ${message}`);
|
|
28
|
+
},
|
|
29
|
+
success(message) {
|
|
30
|
+
console.log(`
|
|
25
31
|
${pc.green("done")} ${message}`);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
},
|
|
33
|
+
warn(message) {
|
|
34
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
35
|
+
},
|
|
36
|
+
error(message) {
|
|
37
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
38
|
+
},
|
|
39
|
+
debug(message) {
|
|
40
|
+
if (debugMode) {
|
|
41
|
+
console.log(`${pc.gray(`[${timestamp()}] debug`)} ${message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
37
45
|
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// src/core/analyzer.ts
|
|
41
|
-
import { createRequire } from "module";
|
|
42
|
-
import sharp from "sharp";
|
|
46
|
+
});
|
|
43
47
|
|
|
44
48
|
// src/constants.ts
|
|
45
49
|
import { tmpdir } from "os";
|
|
46
50
|
import { join } from "path";
|
|
47
|
-
var APP_NAME = "scene-sieve";
|
|
48
|
-
var DEFAULT_COUNT = 20;
|
|
49
|
-
var DEFAULT_THRESHOLD = 0.5;
|
|
50
|
-
var DEFAULT_FPS = 5;
|
|
51
|
-
var DEFAULT_SCALE = 720;
|
|
52
|
-
var DEFAULT_QUALITY = 80;
|
|
53
|
-
var NORMALIZATION_PERCENTILE = 0.9;
|
|
54
|
-
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
55
|
-
var TEMP_BASE_DIR = tmpdir();
|
|
56
|
-
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
57
|
-
".mp4",
|
|
58
|
-
".mov",
|
|
59
|
-
".avi",
|
|
60
|
-
".mkv",
|
|
61
|
-
".webm"
|
|
62
|
-
];
|
|
63
|
-
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
64
|
-
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
65
|
-
var OPENCV_BATCH_SIZE = 10;
|
|
66
|
-
var MIN_IFRAME_COUNT = 3;
|
|
67
|
-
var DBSCAN_ALPHA = 0.03;
|
|
68
|
-
var DBSCAN_MIN_PTS = 4;
|
|
69
|
-
var IOU_THRESHOLD = 0.9;
|
|
70
|
-
var DECAY_LAMBDA = 0.95;
|
|
71
|
-
var ANIMATION_FRAME_THRESHOLD = 5;
|
|
72
|
-
var MATCH_DISTANCE_THRESHOLD = 0.25;
|
|
73
|
-
var PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
74
|
-
var PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
75
|
-
var PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
76
|
-
var PIXELDIFF_SAMPLE_SPACING = 8;
|
|
77
51
|
function getTempWorkspaceDir(sessionId) {
|
|
78
52
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
79
53
|
}
|
|
54
|
+
var APP_NAME, DEFAULT_COUNT, DEFAULT_THRESHOLD, DEFAULT_FPS, DEFAULT_SCALE, DEFAULT_QUALITY, DEFAULT_MAX_FRAMES, NORMALIZATION_PERCENTILE, WORKSPACE_PREFIX, TEMP_BASE_DIR, SUPPORTED_VIDEO_EXTENSIONS, SUPPORTED_GIF_EXTENSIONS, 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;
|
|
55
|
+
var init_constants = __esm({
|
|
56
|
+
"src/constants.ts"() {
|
|
57
|
+
"use strict";
|
|
58
|
+
APP_NAME = "scene-sieve";
|
|
59
|
+
DEFAULT_COUNT = 20;
|
|
60
|
+
DEFAULT_THRESHOLD = 0.5;
|
|
61
|
+
DEFAULT_FPS = 5;
|
|
62
|
+
DEFAULT_SCALE = 720;
|
|
63
|
+
DEFAULT_QUALITY = 80;
|
|
64
|
+
DEFAULT_MAX_FRAMES = 300;
|
|
65
|
+
NORMALIZATION_PERCENTILE = 0.9;
|
|
66
|
+
WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
67
|
+
TEMP_BASE_DIR = tmpdir();
|
|
68
|
+
SUPPORTED_VIDEO_EXTENSIONS = [
|
|
69
|
+
".mp4",
|
|
70
|
+
".mov",
|
|
71
|
+
".avi",
|
|
72
|
+
".mkv",
|
|
73
|
+
".webm"
|
|
74
|
+
];
|
|
75
|
+
SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
76
|
+
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
77
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
78
|
+
OPENCV_BATCH_SIZE = 10;
|
|
79
|
+
DBSCAN_ALPHA = 0.03;
|
|
80
|
+
DBSCAN_MIN_PTS = 4;
|
|
81
|
+
IOU_THRESHOLD = 0.9;
|
|
82
|
+
DECAY_LAMBDA = 0.95;
|
|
83
|
+
ANIMATION_FRAME_THRESHOLD = 5;
|
|
84
|
+
MATCH_DISTANCE_THRESHOLD = 0.25;
|
|
85
|
+
PIXELDIFF_GAUSSIAN_KERNEL = 3;
|
|
86
|
+
PIXELDIFF_BINARY_THRESHOLD = 30;
|
|
87
|
+
PIXELDIFF_CONTOUR_MIN_AREA = 100;
|
|
88
|
+
PIXELDIFF_SAMPLE_SPACING = 8;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
80
91
|
|
|
81
92
|
// src/core/dbscan.ts
|
|
82
|
-
var UNVISITED = -2;
|
|
83
|
-
var NOISE = -1;
|
|
84
93
|
function dbscan(points, imageWidth, imageHeight, alpha, minPts) {
|
|
85
94
|
if (points.length === 0) {
|
|
86
95
|
return { labels: [], boundingBoxes: [] };
|
|
@@ -155,11 +164,19 @@ function findNeighbors(points, idx, epsSquared) {
|
|
|
155
164
|
}
|
|
156
165
|
return neighbors;
|
|
157
166
|
}
|
|
167
|
+
var UNVISITED, NOISE;
|
|
168
|
+
var init_dbscan = __esm({
|
|
169
|
+
"src/core/dbscan.ts"() {
|
|
170
|
+
"use strict";
|
|
171
|
+
init_constants();
|
|
172
|
+
UNVISITED = -2;
|
|
173
|
+
NOISE = -1;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
158
176
|
|
|
159
177
|
// src/core/analyzer.ts
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
var cvReady = null;
|
|
178
|
+
import { createRequire } from "module";
|
|
179
|
+
import sharp from "sharp";
|
|
163
180
|
async function ensureOpenCV() {
|
|
164
181
|
if (!cvReady) {
|
|
165
182
|
cvReady = (async () => {
|
|
@@ -201,67 +218,6 @@ function computeIoU(a, b) {
|
|
|
201
218
|
const union = aArea + bArea - intersection;
|
|
202
219
|
return union === 0 ? 0 : intersection / union;
|
|
203
220
|
}
|
|
204
|
-
var IoUTracker = class {
|
|
205
|
-
regions = [];
|
|
206
|
-
update(boxes, pairIndex) {
|
|
207
|
-
const animationIndices = /* @__PURE__ */ new Set();
|
|
208
|
-
const matched = /* @__PURE__ */ new Set();
|
|
209
|
-
for (let bi = 0; bi < boxes.length; bi++) {
|
|
210
|
-
const box = boxes[bi];
|
|
211
|
-
let bestIoU = 0;
|
|
212
|
-
let bestRegionIdx = -1;
|
|
213
|
-
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
214
|
-
if (matched.has(ri)) continue;
|
|
215
|
-
const iou = computeIoU(box, this.regions[ri].box);
|
|
216
|
-
if (iou > bestIoU) {
|
|
217
|
-
bestIoU = iou;
|
|
218
|
-
bestRegionIdx = ri;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (bestIoU > IOU_THRESHOLD && bestRegionIdx !== -1) {
|
|
222
|
-
const region = this.regions[bestRegionIdx];
|
|
223
|
-
const gap = pairIndex - region.lastSeen;
|
|
224
|
-
region.box = box;
|
|
225
|
-
region.consecutiveCount++;
|
|
226
|
-
region.lastSeen = pairIndex;
|
|
227
|
-
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
228
|
-
matched.add(bestRegionIdx);
|
|
229
|
-
if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
|
|
230
|
-
animationIndices.add(bi);
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
this.regions.push({
|
|
234
|
-
box,
|
|
235
|
-
consecutiveCount: 1,
|
|
236
|
-
lastSeen: pairIndex,
|
|
237
|
-
weight: 1
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
242
|
-
if (!matched.has(ri)) {
|
|
243
|
-
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
244
|
-
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
this.regions = this.regions.filter((r) => r.weight > 0.01);
|
|
248
|
-
return animationIndices;
|
|
249
|
-
}
|
|
250
|
-
getAnimationWeight(boxIndex, boxes) {
|
|
251
|
-
if (boxIndex >= boxes.length) return 0;
|
|
252
|
-
const box = boxes[boxIndex];
|
|
253
|
-
let maxWeight = 0;
|
|
254
|
-
for (const region of this.regions) {
|
|
255
|
-
if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
|
|
256
|
-
const iou = computeIoU(box, region.box);
|
|
257
|
-
if (iou > IOU_THRESHOLD) {
|
|
258
|
-
maxWeight = Math.max(maxWeight, region.weight);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return maxWeight;
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
221
|
async function computeAKAZEDiff(cvLib, frame1, frame2) {
|
|
266
222
|
const cv = cvLib;
|
|
267
223
|
const mat1 = new cv.Mat(frame1.height, frame1.width, cv.CV_8UC1);
|
|
@@ -476,13 +432,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
476
432
|
}
|
|
477
433
|
async function analyzeFrames(ctx) {
|
|
478
434
|
const { frames } = ctx;
|
|
479
|
-
if (frames.length < 2) return [];
|
|
435
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
480
436
|
logger.debug(
|
|
481
437
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
482
438
|
);
|
|
483
439
|
const cvLib = await ensureOpenCV();
|
|
484
440
|
const edges = [];
|
|
485
|
-
const tracker = new IoUTracker(
|
|
441
|
+
const tracker = new IoUTracker(
|
|
442
|
+
ctx.options.fps,
|
|
443
|
+
ctx.options.iouThreshold,
|
|
444
|
+
ctx.options.animationThreshold
|
|
445
|
+
);
|
|
486
446
|
const scale = ctx.options.scale;
|
|
487
447
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
488
448
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -495,16 +455,120 @@ async function analyzeFrames(ctx) {
|
|
|
495
455
|
);
|
|
496
456
|
ctx.emitProgress(progress);
|
|
497
457
|
}
|
|
498
|
-
|
|
499
|
-
|
|
458
|
+
const animations = tracker.flushAndGetAnimations();
|
|
459
|
+
logger.debug(
|
|
460
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
461
|
+
);
|
|
462
|
+
return { edges, animations };
|
|
500
463
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
464
|
+
var OPENCV_INIT_TIMEOUT_MS, require2, cvReady, IoUTracker;
|
|
465
|
+
var init_analyzer = __esm({
|
|
466
|
+
"src/core/analyzer.ts"() {
|
|
467
|
+
"use strict";
|
|
468
|
+
init_constants();
|
|
469
|
+
init_logger();
|
|
470
|
+
init_dbscan();
|
|
471
|
+
OPENCV_INIT_TIMEOUT_MS = 3e4;
|
|
472
|
+
require2 = createRequire(import.meta.url);
|
|
473
|
+
cvReady = null;
|
|
474
|
+
IoUTracker = class {
|
|
475
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
476
|
+
this.fps = fps;
|
|
477
|
+
this.iouThreshold = iouThreshold;
|
|
478
|
+
this.animationThreshold = animationThreshold;
|
|
479
|
+
}
|
|
480
|
+
regions = [];
|
|
481
|
+
extractedAnimations = [];
|
|
482
|
+
update(boxes, pairIndex) {
|
|
483
|
+
const animationIndices = /* @__PURE__ */ new Set();
|
|
484
|
+
const matched = /* @__PURE__ */ new Set();
|
|
485
|
+
for (let bi = 0; bi < boxes.length; bi++) {
|
|
486
|
+
const box = boxes[bi];
|
|
487
|
+
let bestIoU = 0;
|
|
488
|
+
let bestRegionIdx = -1;
|
|
489
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
490
|
+
if (matched.has(ri)) continue;
|
|
491
|
+
const iou = computeIoU(box, this.regions[ri].box);
|
|
492
|
+
if (iou > bestIoU) {
|
|
493
|
+
bestIoU = iou;
|
|
494
|
+
bestRegionIdx = ri;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
498
|
+
const region = this.regions[bestRegionIdx];
|
|
499
|
+
const gap = pairIndex - region.lastSeen;
|
|
500
|
+
region.box = box;
|
|
501
|
+
region.consecutiveCount++;
|
|
502
|
+
region.lastSeen = pairIndex;
|
|
503
|
+
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
504
|
+
matched.add(bestRegionIdx);
|
|
505
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
506
|
+
animationIndices.add(bi);
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
this.regions.push({
|
|
510
|
+
box,
|
|
511
|
+
consecutiveCount: 1,
|
|
512
|
+
firstSeen: pairIndex,
|
|
513
|
+
lastSeen: pairIndex,
|
|
514
|
+
weight: 1
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (let ri = 0; ri < this.regions.length; ri++) {
|
|
519
|
+
if (!matched.has(ri)) {
|
|
520
|
+
const gap = pairIndex - this.regions[ri].lastSeen;
|
|
521
|
+
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
525
|
+
const region = this.regions[i];
|
|
526
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
527
|
+
this.collectAnimation(region);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
this.regions = this.regions.filter(
|
|
531
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
532
|
+
);
|
|
533
|
+
return animationIndices;
|
|
534
|
+
}
|
|
535
|
+
collectAnimation(region) {
|
|
536
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
537
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
538
|
+
this.extractedAnimations.push({
|
|
539
|
+
type: "loading_spinner",
|
|
540
|
+
// 기본값으로 loading_spinner 사용
|
|
541
|
+
boundingBox: region.box,
|
|
542
|
+
startFrameId: region.firstSeen,
|
|
543
|
+
endFrameId: region.lastSeen,
|
|
544
|
+
durationMs
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
flushAndGetAnimations() {
|
|
549
|
+
for (const region of this.regions) {
|
|
550
|
+
this.collectAnimation(region);
|
|
551
|
+
}
|
|
552
|
+
this.regions = [];
|
|
553
|
+
return this.extractedAnimations;
|
|
554
|
+
}
|
|
555
|
+
getAnimationWeight(boxIndex, boxes) {
|
|
556
|
+
if (boxIndex >= boxes.length) return 0;
|
|
557
|
+
const box = boxes[boxIndex];
|
|
558
|
+
let maxWeight = 0;
|
|
559
|
+
for (const region of this.regions) {
|
|
560
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
561
|
+
const iou = computeIoU(box, region.box);
|
|
562
|
+
if (iou > this.iouThreshold) {
|
|
563
|
+
maxWeight = Math.max(maxWeight, region.weight);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return maxWeight;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
});
|
|
508
572
|
|
|
509
573
|
// src/utils/paths.ts
|
|
510
574
|
import { mkdir, stat } from "fs/promises";
|
|
@@ -539,11 +603,21 @@ function deriveOutputPath(inputPath) {
|
|
|
539
603
|
function isSupportedFile(filePath, extensions) {
|
|
540
604
|
return extensions.includes(extname(filePath).toLowerCase());
|
|
541
605
|
}
|
|
606
|
+
var init_paths = __esm({
|
|
607
|
+
"src/utils/paths.ts"() {
|
|
608
|
+
"use strict";
|
|
609
|
+
}
|
|
610
|
+
});
|
|
542
611
|
|
|
543
612
|
// src/core/extractor.ts
|
|
613
|
+
import { readdir } from "fs/promises";
|
|
614
|
+
import { join as join2 } from "path";
|
|
615
|
+
import { path as ffprobePath } from "@ffprobe-installer/ffprobe";
|
|
616
|
+
import { execa } from "execa";
|
|
617
|
+
import ffmpegPath from "ffmpeg-static";
|
|
544
618
|
async function extractFrames(ctx) {
|
|
545
619
|
const framesDir = join2(ctx.workspacePath, "frames");
|
|
546
|
-
const { inputPath, fps, scale } = ctx.options;
|
|
620
|
+
const { inputPath, fps, maxFrames, scale } = ctx.options;
|
|
547
621
|
if (!inputPath) {
|
|
548
622
|
throw new Error("inputPath is required for frame extraction");
|
|
549
623
|
}
|
|
@@ -560,41 +634,23 @@ async function extractFrames(ctx) {
|
|
|
560
634
|
}
|
|
561
635
|
logger.debug(`Extracting frames from: ${inputPath}`);
|
|
562
636
|
await ensureDir(framesDir);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
`Insufficient I-frames (${frames.length}), falling back to FPS mode`
|
|
573
|
-
);
|
|
574
|
-
frames = await extractByFps(inputPath, framesDir, fps, scale);
|
|
575
|
-
}
|
|
637
|
+
let effectiveFps = fps;
|
|
638
|
+
const duration = await getVideoDuration(inputPath).catch(() => 0);
|
|
639
|
+
if (duration > 0) {
|
|
640
|
+
const fpsCap = maxFrames / duration;
|
|
641
|
+
effectiveFps = Math.min(fps, fpsCap);
|
|
642
|
+
effectiveFps = Math.max(0.5, effectiveFps);
|
|
643
|
+
logger.debug(
|
|
644
|
+
`Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
645
|
+
);
|
|
576
646
|
}
|
|
647
|
+
const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
|
|
577
648
|
ctx.emitProgress(100);
|
|
578
649
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
579
650
|
return frames;
|
|
580
651
|
}
|
|
581
|
-
async function extractIFrames(inputPath, outputDir, scale) {
|
|
582
|
-
const outputPattern = join2(outputDir, "frame_%06d.jpg");
|
|
583
|
-
await execa(ffmpegPath, [
|
|
584
|
-
"-i",
|
|
585
|
-
inputPath,
|
|
586
|
-
"-vf",
|
|
587
|
-
`select='eq(pict_type,I)',scale=-1:${scale}`,
|
|
588
|
-
"-vsync",
|
|
589
|
-
"vfr",
|
|
590
|
-
"-q:v",
|
|
591
|
-
"2",
|
|
592
|
-
outputPattern
|
|
593
|
-
]);
|
|
594
|
-
return buildFrameList(outputDir, inputPath);
|
|
595
|
-
}
|
|
596
652
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
597
|
-
const outputPattern = join2(outputDir,
|
|
653
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
598
654
|
await execa(ffmpegPath, [
|
|
599
655
|
"-i",
|
|
600
656
|
inputPath,
|
|
@@ -638,12 +694,17 @@ async function buildFrameList(framesDir, inputPath) {
|
|
|
638
694
|
extractPath: join2(framesDir, file)
|
|
639
695
|
}));
|
|
640
696
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
697
|
+
var init_extractor = __esm({
|
|
698
|
+
"src/core/extractor.ts"() {
|
|
699
|
+
"use strict";
|
|
700
|
+
init_constants();
|
|
701
|
+
init_logger();
|
|
702
|
+
init_paths();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
644
705
|
|
|
645
706
|
// src/core/workspace.ts
|
|
646
|
-
import { rename, rm, writeFile } from "fs/promises";
|
|
707
|
+
import { readdir as readdir2, rename, rm, stat as stat2, writeFile } from "fs/promises";
|
|
647
708
|
import { join as join3 } from "path";
|
|
648
709
|
import sharp2 from "sharp";
|
|
649
710
|
async function createWorkspace(sessionId) {
|
|
@@ -657,13 +718,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
657
718
|
const outputPath = ctx.options.outputPath;
|
|
658
719
|
const quality = ctx.options.quality;
|
|
659
720
|
const outputFiles = [];
|
|
721
|
+
const framesMetadata = [];
|
|
722
|
+
const totalFramesCount = ctx.frames.length;
|
|
723
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
660
724
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
661
725
|
const frame = selectedFrames[i];
|
|
662
|
-
const
|
|
663
|
-
const destPath = join3(stagingDir,
|
|
726
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
727
|
+
const destPath = join3(stagingDir, fileName);
|
|
664
728
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
665
|
-
outputFiles.push(join3(outputPath,
|
|
729
|
+
outputFiles.push(join3(outputPath, fileName));
|
|
730
|
+
framesMetadata.push({
|
|
731
|
+
step: i + 1,
|
|
732
|
+
fileName,
|
|
733
|
+
frameId: frame.id + 1,
|
|
734
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
735
|
+
});
|
|
666
736
|
}
|
|
737
|
+
const metadata = {
|
|
738
|
+
video: {
|
|
739
|
+
originalDurationMs: Math.round(
|
|
740
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
741
|
+
),
|
|
742
|
+
fps: ctx.options.fps,
|
|
743
|
+
resolution: {
|
|
744
|
+
width: ctx.options.scale,
|
|
745
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
frames: framesMetadata,
|
|
749
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
750
|
+
...anim,
|
|
751
|
+
startFrameId: anim.startFrameId + 1,
|
|
752
|
+
endFrameId: anim.endFrameId + 1,
|
|
753
|
+
durationMs: Math.round(anim.durationMs)
|
|
754
|
+
}))
|
|
755
|
+
};
|
|
756
|
+
const metadataPath = join3(stagingDir, ".metadata.json");
|
|
757
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
758
|
+
outputFiles.push(join3(outputPath, ".metadata.json"));
|
|
667
759
|
await ensureDir(join3(outputPath, ".."));
|
|
668
760
|
await rm(outputPath, { recursive: true, force: true });
|
|
669
761
|
await rename(stagingDir, outputPath);
|
|
@@ -676,6 +768,21 @@ async function cleanupWorkspace(workspacePath) {
|
|
|
676
768
|
} catch {
|
|
677
769
|
}
|
|
678
770
|
}
|
|
771
|
+
async function cleanupStaleWorkspaces() {
|
|
772
|
+
const entries = await readdir2(TEMP_BASE_DIR);
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
for (const entry of entries) {
|
|
775
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
776
|
+
const fullPath = join3(TEMP_BASE_DIR, entry);
|
|
777
|
+
try {
|
|
778
|
+
const info = await stat2(fullPath);
|
|
779
|
+
if (info.isDirectory() && now - info.mtimeMs > STALE_THRESHOLD_MS) {
|
|
780
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
781
|
+
}
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
679
786
|
async function writeInputBuffer(buffer, workspacePath) {
|
|
680
787
|
const inputDir = join3(workspacePath, "input");
|
|
681
788
|
await ensureDir(inputDir);
|
|
@@ -702,8 +809,18 @@ async function readFramesAsBuffers(frameNodes, quality) {
|
|
|
702
809
|
)
|
|
703
810
|
);
|
|
704
811
|
}
|
|
812
|
+
var STALE_THRESHOLD_MS;
|
|
813
|
+
var init_workspace = __esm({
|
|
814
|
+
"src/core/workspace.ts"() {
|
|
815
|
+
"use strict";
|
|
816
|
+
init_constants();
|
|
817
|
+
init_paths();
|
|
818
|
+
STALE_THRESHOLD_MS = 60 * 60 * 1e3;
|
|
819
|
+
}
|
|
820
|
+
});
|
|
705
821
|
|
|
706
822
|
// src/core/input-resolver.ts
|
|
823
|
+
import { join as join4 } from "path";
|
|
707
824
|
function resolveOptions(options) {
|
|
708
825
|
const mode = options.mode;
|
|
709
826
|
const inputPath = mode === "file" ? resolveAbsolute(options.inputPath) : void 0;
|
|
@@ -723,8 +840,11 @@ function resolveOptions(options) {
|
|
|
723
840
|
pruneMode,
|
|
724
841
|
outputPath,
|
|
725
842
|
fps: options.fps ?? DEFAULT_FPS,
|
|
843
|
+
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
726
844
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
727
845
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
846
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
847
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
728
848
|
debug: options.debug ?? false
|
|
729
849
|
};
|
|
730
850
|
}
|
|
@@ -748,50 +868,64 @@ async function resolveInput(options, workspacePath) {
|
|
|
748
868
|
}
|
|
749
869
|
throw new Error(`Unsupported input mode: ${options.mode}`);
|
|
750
870
|
}
|
|
871
|
+
var init_input_resolver = __esm({
|
|
872
|
+
"src/core/input-resolver.ts"() {
|
|
873
|
+
"use strict";
|
|
874
|
+
init_constants();
|
|
875
|
+
init_paths();
|
|
876
|
+
init_workspace();
|
|
877
|
+
}
|
|
878
|
+
});
|
|
751
879
|
|
|
752
880
|
// src/utils/min-heap.ts
|
|
753
|
-
var MinHeap
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
881
|
+
var MinHeap;
|
|
882
|
+
var init_min_heap = __esm({
|
|
883
|
+
"src/utils/min-heap.ts"() {
|
|
884
|
+
"use strict";
|
|
885
|
+
MinHeap = class {
|
|
886
|
+
h = [];
|
|
887
|
+
get size() {
|
|
888
|
+
return this.h.length;
|
|
889
|
+
}
|
|
890
|
+
push(entry) {
|
|
891
|
+
this.h.push(entry);
|
|
892
|
+
this.siftUp(this.h.length - 1);
|
|
893
|
+
}
|
|
894
|
+
pop() {
|
|
895
|
+
const n = this.h.length;
|
|
896
|
+
if (n === 0) return void 0;
|
|
897
|
+
const top = this.h[0];
|
|
898
|
+
const last = this.h.pop();
|
|
899
|
+
if (n > 1) {
|
|
900
|
+
this.h[0] = last;
|
|
901
|
+
this.siftDown(0);
|
|
902
|
+
}
|
|
903
|
+
return top;
|
|
904
|
+
}
|
|
905
|
+
siftUp(i) {
|
|
906
|
+
while (i > 0) {
|
|
907
|
+
const p = i - 1 >> 1;
|
|
908
|
+
if (this.h[p].score <= this.h[i].score) break;
|
|
909
|
+
[this.h[p], this.h[i]] = [this.h[i], this.h[p]];
|
|
910
|
+
i = p;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
siftDown(i) {
|
|
914
|
+
const n = this.h.length;
|
|
915
|
+
for (; ; ) {
|
|
916
|
+
let m = i;
|
|
917
|
+
const l = 2 * i + 1;
|
|
918
|
+
const r = 2 * i + 2;
|
|
919
|
+
if (l < n && this.h[l].score < this.h[m].score) m = l;
|
|
920
|
+
if (r < n && this.h[r].score < this.h[m].score) m = r;
|
|
921
|
+
if (m === i) break;
|
|
922
|
+
[this.h[m], this.h[i]] = [this.h[i], this.h[m]];
|
|
923
|
+
i = m;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
793
927
|
}
|
|
794
|
-
};
|
|
928
|
+
});
|
|
795
929
|
|
|
796
930
|
// src/core/pruner.ts
|
|
797
931
|
function pruneTo(graph, frames, targetCount) {
|
|
@@ -953,8 +1087,20 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
|
|
|
953
1087
|
}
|
|
954
1088
|
return pruneTo(syntheticEdges, survivingFrames, maxCount);
|
|
955
1089
|
}
|
|
1090
|
+
var init_pruner = __esm({
|
|
1091
|
+
"src/core/pruner.ts"() {
|
|
1092
|
+
"use strict";
|
|
1093
|
+
init_constants();
|
|
1094
|
+
init_min_heap();
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
956
1097
|
|
|
957
1098
|
// src/core/orchestrator.ts
|
|
1099
|
+
var orchestrator_exports = {};
|
|
1100
|
+
__export(orchestrator_exports, {
|
|
1101
|
+
runPipeline: () => runPipeline
|
|
1102
|
+
});
|
|
1103
|
+
import { randomUUID } from "crypto";
|
|
958
1104
|
async function runPipeline(options) {
|
|
959
1105
|
const startTime = Date.now();
|
|
960
1106
|
const sessionId = randomUUID();
|
|
@@ -995,7 +1141,9 @@ async function runPipeline(options) {
|
|
|
995
1141
|
}
|
|
996
1142
|
ctx.emitProgress(100);
|
|
997
1143
|
ctx.status = "ANALYZING";
|
|
998
|
-
|
|
1144
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1145
|
+
ctx.graph = edges;
|
|
1146
|
+
ctx.animations = animations;
|
|
999
1147
|
ctx.status = "PRUNING";
|
|
1000
1148
|
const survivingIds = pruneByThresholdWithCap(
|
|
1001
1149
|
ctx.graph,
|
|
@@ -1028,6 +1176,15 @@ async function runPipeline(options) {
|
|
|
1028
1176
|
prunedFramesCount: prunedFrames.length,
|
|
1029
1177
|
outputFiles,
|
|
1030
1178
|
outputBuffers,
|
|
1179
|
+
animations: ctx.animations,
|
|
1180
|
+
video: {
|
|
1181
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1182
|
+
fps: ctx.options.fps,
|
|
1183
|
+
resolution: {
|
|
1184
|
+
width: ctx.options.scale,
|
|
1185
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1031
1188
|
executionTimeMs: Date.now() - startTime
|
|
1032
1189
|
};
|
|
1033
1190
|
} catch (error) {
|
|
@@ -1043,58 +1200,339 @@ async function runPipeline(options) {
|
|
|
1043
1200
|
}
|
|
1044
1201
|
}
|
|
1045
1202
|
}
|
|
1203
|
+
var init_orchestrator = __esm({
|
|
1204
|
+
"src/core/orchestrator.ts"() {
|
|
1205
|
+
"use strict";
|
|
1206
|
+
init_logger();
|
|
1207
|
+
init_analyzer();
|
|
1208
|
+
init_extractor();
|
|
1209
|
+
init_input_resolver();
|
|
1210
|
+
init_pruner();
|
|
1211
|
+
init_workspace();
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1046
1214
|
|
|
1047
1215
|
// src/cli.ts
|
|
1216
|
+
import { createRequire as createRequire2 } from "module";
|
|
1217
|
+
import { Command } from "commander";
|
|
1218
|
+
import { render } from "ink";
|
|
1219
|
+
import React2 from "react";
|
|
1220
|
+
|
|
1221
|
+
// src/commands/Sieve.tsx
|
|
1222
|
+
import { Box as Box2, Text as Text3, useApp } from "ink";
|
|
1223
|
+
import { useEffect, useState } from "react";
|
|
1224
|
+
|
|
1225
|
+
// src/components/PhaseStep.tsx
|
|
1226
|
+
import { Box, Text as Text2 } from "ink";
|
|
1227
|
+
import Spinner from "ink-spinner";
|
|
1228
|
+
|
|
1229
|
+
// src/components/ProgressBar.tsx
|
|
1230
|
+
import { Text } from "ink";
|
|
1231
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1232
|
+
var ProgressBar = ({
|
|
1233
|
+
percent,
|
|
1234
|
+
width = 30
|
|
1235
|
+
}) => {
|
|
1236
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
1237
|
+
const filled = Math.round(width * (clamped / 100));
|
|
1238
|
+
const empty = width - filled;
|
|
1239
|
+
return /* @__PURE__ */ jsxs(Text, { children: [
|
|
1240
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: "\u2588".repeat(filled) }),
|
|
1241
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", children: "\u2591".repeat(empty) }),
|
|
1242
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1243
|
+
" ",
|
|
1244
|
+
clamped,
|
|
1245
|
+
"%"
|
|
1246
|
+
] })
|
|
1247
|
+
] });
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// src/components/PhaseStep.tsx
|
|
1251
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1252
|
+
var PhaseStep = ({ phase }) => {
|
|
1253
|
+
const icon = (() => {
|
|
1254
|
+
switch (phase.status) {
|
|
1255
|
+
case "done":
|
|
1256
|
+
return /* @__PURE__ */ jsx2(Text2, { color: "green", children: "\u2713" });
|
|
1257
|
+
case "running":
|
|
1258
|
+
return /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: /* @__PURE__ */ jsx2(Spinner, { type: "dots" }) });
|
|
1259
|
+
case "failed":
|
|
1260
|
+
return /* @__PURE__ */ jsx2(Text2, { color: "red", children: "\u2717" });
|
|
1261
|
+
default:
|
|
1262
|
+
return /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u25CB" });
|
|
1263
|
+
}
|
|
1264
|
+
})();
|
|
1265
|
+
const duration = phase.status === "done" && phase.durationMs !== void 0 ? `Done (${Math.round(phase.durationMs / 1e3)}s)` : "";
|
|
1266
|
+
return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
|
|
1267
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1268
|
+
" ",
|
|
1269
|
+
icon,
|
|
1270
|
+
" ",
|
|
1271
|
+
phase.label,
|
|
1272
|
+
duration ? /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
|
|
1273
|
+
" ",
|
|
1274
|
+
duration
|
|
1275
|
+
] }) : null
|
|
1276
|
+
] }),
|
|
1277
|
+
phase.status === "running" && phase.hasProgress && phase.percent > 0 && /* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1278
|
+
" ",
|
|
1279
|
+
/* @__PURE__ */ jsx2(ProgressBar, { percent: phase.percent })
|
|
1280
|
+
] })
|
|
1281
|
+
] });
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
// src/core/run-in-worker.ts
|
|
1285
|
+
import { dirname, join as join5 } from "path";
|
|
1286
|
+
import { fileURLToPath } from "url";
|
|
1287
|
+
import { Worker } from "worker_threads";
|
|
1288
|
+
async function runPipelineInWorker(options, onProgress) {
|
|
1289
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
1290
|
+
if (!currentFile.endsWith(".mjs")) {
|
|
1291
|
+
const { runPipeline: runPipeline2 } = await Promise.resolve().then(() => (init_orchestrator(), orchestrator_exports));
|
|
1292
|
+
return runPipeline2({ ...options, onProgress });
|
|
1293
|
+
}
|
|
1294
|
+
const workerPath = join5(dirname(currentFile), "pipeline-worker.mjs");
|
|
1295
|
+
return new Promise((resolve2, reject) => {
|
|
1296
|
+
const worker = new Worker(workerPath, { workerData: options });
|
|
1297
|
+
worker.on(
|
|
1298
|
+
"message",
|
|
1299
|
+
(msg) => {
|
|
1300
|
+
if (msg.type === "progress" && msg.phase && msg.percent !== void 0) {
|
|
1301
|
+
onProgress(msg.phase, msg.percent);
|
|
1302
|
+
} else if (msg.type === "result") {
|
|
1303
|
+
resolve2(msg.result);
|
|
1304
|
+
worker.terminate();
|
|
1305
|
+
} else if (msg.type === "error") {
|
|
1306
|
+
reject(new Error(msg.message));
|
|
1307
|
+
worker.terminate();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
);
|
|
1311
|
+
worker.on("error", reject);
|
|
1312
|
+
worker.on("exit", (code) => {
|
|
1313
|
+
if (code !== 0 && code !== 1) {
|
|
1314
|
+
reject(new Error(`Worker exited with code ${code}`));
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/commands/Sieve.tsx
|
|
1321
|
+
init_workspace();
|
|
1322
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1323
|
+
var PHASE_DEFS = [
|
|
1324
|
+
{ key: "INIT", label: "Initializing workspace", hasProgress: false },
|
|
1325
|
+
{ key: "EXTRACTING", label: "Extracting frames", hasProgress: false },
|
|
1326
|
+
{ key: "ANALYZING", label: "Analyzing frame similarity", hasProgress: true },
|
|
1327
|
+
{ key: "PRUNING", label: "Pruning similar frames", hasProgress: false },
|
|
1328
|
+
{ key: "FINALIZING", label: "Finalizing output", hasProgress: false }
|
|
1329
|
+
];
|
|
1330
|
+
function createInitialPhases() {
|
|
1331
|
+
return PHASE_DEFS.map((def) => ({
|
|
1332
|
+
label: def.label,
|
|
1333
|
+
status: "pending",
|
|
1334
|
+
hasProgress: def.hasProgress,
|
|
1335
|
+
percent: 0
|
|
1336
|
+
}));
|
|
1337
|
+
}
|
|
1338
|
+
function phaseKeyToIndex(phase) {
|
|
1339
|
+
return PHASE_DEFS.findIndex((d) => d.key === phase);
|
|
1340
|
+
}
|
|
1341
|
+
var SieveView = (props) => {
|
|
1342
|
+
const { exit } = useApp();
|
|
1343
|
+
const [phases, setPhases] = useState(createInitialPhases);
|
|
1344
|
+
const [result, setResult] = useState(null);
|
|
1345
|
+
const [error, setError] = useState(null);
|
|
1346
|
+
useEffect(() => {
|
|
1347
|
+
const phaseStartTimes = PHASE_DEFS.map(() => 0);
|
|
1348
|
+
let currentPhaseKey = "";
|
|
1349
|
+
(async () => {
|
|
1350
|
+
try {
|
|
1351
|
+
await cleanupStaleWorkspaces().catch(() => {
|
|
1352
|
+
});
|
|
1353
|
+
phaseStartTimes[0] = Date.now();
|
|
1354
|
+
setPhases((prev) => {
|
|
1355
|
+
const next = [...prev];
|
|
1356
|
+
next[0] = { ...next[0], status: "running" };
|
|
1357
|
+
return next;
|
|
1358
|
+
});
|
|
1359
|
+
const res = await runPipelineInWorker(
|
|
1360
|
+
{
|
|
1361
|
+
mode: "file",
|
|
1362
|
+
inputPath: props.input,
|
|
1363
|
+
...props.threshold !== void 0 ? { threshold: props.threshold } : {},
|
|
1364
|
+
...props.count !== void 0 ? { count: props.count } : {},
|
|
1365
|
+
outputPath: props.output,
|
|
1366
|
+
fps: props.fps,
|
|
1367
|
+
maxFrames: props.maxFrames,
|
|
1368
|
+
scale: props.scale,
|
|
1369
|
+
quality: props.quality,
|
|
1370
|
+
iouThreshold: props.iouThreshold,
|
|
1371
|
+
animationThreshold: props.animationThreshold,
|
|
1372
|
+
debug: props.debug
|
|
1373
|
+
},
|
|
1374
|
+
(phase, percent) => {
|
|
1375
|
+
const phaseIdx = phaseKeyToIndex(phase);
|
|
1376
|
+
if (phaseIdx < 0) return;
|
|
1377
|
+
if (phase !== currentPhaseKey) {
|
|
1378
|
+
const now2 = Date.now();
|
|
1379
|
+
currentPhaseKey = phase;
|
|
1380
|
+
phaseStartTimes[phaseIdx] = now2;
|
|
1381
|
+
setPhases((prev) => {
|
|
1382
|
+
const next = [...prev];
|
|
1383
|
+
for (let i = 0; i < next.length; i++) {
|
|
1384
|
+
if (i < phaseIdx) {
|
|
1385
|
+
if (next[i].status !== "done") {
|
|
1386
|
+
next[i] = {
|
|
1387
|
+
...next[i],
|
|
1388
|
+
status: "done",
|
|
1389
|
+
percent: 100,
|
|
1390
|
+
durationMs: phaseStartTimes[i] ? now2 - phaseStartTimes[i] : 0
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
} else if (i === phaseIdx) {
|
|
1394
|
+
next[i] = { ...next[i], status: "running", percent: 0 };
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return next;
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
setPhases((prev) => {
|
|
1401
|
+
const next = [...prev];
|
|
1402
|
+
if (next[phaseIdx].status === "running") {
|
|
1403
|
+
next[phaseIdx] = {
|
|
1404
|
+
...next[phaseIdx],
|
|
1405
|
+
percent: Math.round(percent)
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
return next;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
);
|
|
1412
|
+
const now = Date.now();
|
|
1413
|
+
setPhases(
|
|
1414
|
+
(prev) => prev.map((p, i) => {
|
|
1415
|
+
if (p.status !== "done") {
|
|
1416
|
+
return {
|
|
1417
|
+
...p,
|
|
1418
|
+
status: "done",
|
|
1419
|
+
percent: 100,
|
|
1420
|
+
durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
return p;
|
|
1424
|
+
})
|
|
1425
|
+
);
|
|
1426
|
+
setResult(res);
|
|
1427
|
+
setTimeout(() => exit(), 100);
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
const now = Date.now();
|
|
1430
|
+
setPhases((prev) => {
|
|
1431
|
+
const next = [...prev];
|
|
1432
|
+
for (let i = 0; i < next.length; i++) {
|
|
1433
|
+
if (next[i].status === "running") {
|
|
1434
|
+
next[i] = {
|
|
1435
|
+
...next[i],
|
|
1436
|
+
status: "failed",
|
|
1437
|
+
durationMs: phaseStartTimes[i] ? now - phaseStartTimes[i] : 0
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return next;
|
|
1442
|
+
});
|
|
1443
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
1444
|
+
setTimeout(() => exit(), 100);
|
|
1445
|
+
}
|
|
1446
|
+
})();
|
|
1447
|
+
}, []);
|
|
1448
|
+
return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
|
|
1449
|
+
/* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
|
|
1450
|
+
"\u25B8 scene-sieve",
|
|
1451
|
+
" \u2014 ",
|
|
1452
|
+
props.input.split("/").pop()
|
|
1453
|
+
] }),
|
|
1454
|
+
/* @__PURE__ */ jsx3(Text3, { children: " " }),
|
|
1455
|
+
phases.map((phase, i) => /* @__PURE__ */ jsx3(PhaseStep, { phase }, i)),
|
|
1456
|
+
error && /* @__PURE__ */ jsx3(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
|
|
1457
|
+
"\u2717 Failed \u2014 ",
|
|
1458
|
+
error
|
|
1459
|
+
] }) }),
|
|
1460
|
+
result && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
1461
|
+
/* @__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" }),
|
|
1462
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "green", bold: true, children: [
|
|
1463
|
+
"\u2713 Done",
|
|
1464
|
+
" \u2014 ",
|
|
1465
|
+
result.originalFramesCount,
|
|
1466
|
+
" frames \u2192",
|
|
1467
|
+
" ",
|
|
1468
|
+
result.prunedFramesCount,
|
|
1469
|
+
" scenes (",
|
|
1470
|
+
(result.executionTimeMs / 1e3).toFixed(1),
|
|
1471
|
+
"s)"
|
|
1472
|
+
] }),
|
|
1473
|
+
result.animations && result.animations.length > 0 && /* @__PURE__ */ jsxs3(Text3, { color: "blue", children: [
|
|
1474
|
+
"\u2139 Found",
|
|
1475
|
+
" ",
|
|
1476
|
+
result.animations.length,
|
|
1477
|
+
" animations (recorded in .metadata.json)"
|
|
1478
|
+
] }),
|
|
1479
|
+
props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
1480
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
1481
|
+
"Output: ",
|
|
1482
|
+
result.outputFiles[0]?.replace(/\/[^/]+$/, "/")
|
|
1483
|
+
] }),
|
|
1484
|
+
result.outputFiles.map((f, i) => /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
1485
|
+
" - ",
|
|
1486
|
+
f
|
|
1487
|
+
] }, i))
|
|
1488
|
+
] })
|
|
1489
|
+
] })
|
|
1490
|
+
] });
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// src/cli.ts
|
|
1494
|
+
init_constants();
|
|
1048
1495
|
var require3 = createRequire2(import.meta.url);
|
|
1049
1496
|
var { version } = require3("../package.json");
|
|
1050
1497
|
var program = new Command();
|
|
1051
1498
|
program.name("scene-sieve").description("Extract key frames from video and GIF files").version(version).argument("<input>", "Input video or GIF file path").option("-n, --count <number>", "Max number of frames to keep (default: 20)").option(
|
|
1052
1499
|
"-t, --threshold <number>",
|
|
1053
1500
|
"Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
|
|
1054
|
-
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1501
|
+
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction", String(DEFAULT_FPS)).option(
|
|
1502
|
+
"-mf, --max-frames <number>",
|
|
1503
|
+
"Max frames to extract (auto-reduces FPS for long videos)",
|
|
1504
|
+
String(DEFAULT_MAX_FRAMES)
|
|
1505
|
+
).option(
|
|
1506
|
+
"-s, --scale <number>",
|
|
1507
|
+
"Scale size for vision analysis",
|
|
1508
|
+
String(DEFAULT_SCALE)
|
|
1509
|
+
).option(
|
|
1510
|
+
"-q, --quality <number>",
|
|
1511
|
+
"JPEG output quality 1-100",
|
|
1512
|
+
String(DEFAULT_QUALITY)
|
|
1513
|
+
).option(
|
|
1514
|
+
"-it, --iou-threshold <number>",
|
|
1515
|
+
`IoU threshold for animation tracking (0-1) (default: ${IOU_THRESHOLD})`
|
|
1516
|
+
).option(
|
|
1517
|
+
"-at, --anim-threshold <number>",
|
|
1518
|
+
`Min consecutive frames for animation (default: ${ANIMATION_FRAME_THRESHOLD})`
|
|
1519
|
+
).option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
|
|
1520
|
+
const { waitUntilExit } = render(
|
|
1521
|
+
React2.createElement(SieveView, {
|
|
1522
|
+
input,
|
|
1070
1523
|
...opts.threshold !== void 0 ? { threshold: parseFloat(opts.threshold) } : {},
|
|
1071
1524
|
...opts.count !== void 0 ? { count: parseInt(opts.count, 10) } : {},
|
|
1072
|
-
|
|
1525
|
+
output: opts.output,
|
|
1073
1526
|
fps: parseInt(opts.fps, 10),
|
|
1527
|
+
maxFrames: parseInt(opts.maxFrames, 10),
|
|
1074
1528
|
scale: parseInt(opts.scale, 10),
|
|
1075
1529
|
quality: parseInt(opts.quality, 10),
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
console.log(
|
|
1083
|
-
`
|
|
1084
|
-
Done! ${result.originalFramesCount} frames -> ${result.prunedFramesCount} scenes (${result.executionTimeMs}ms)`
|
|
1085
|
-
);
|
|
1086
|
-
if (opts.debug) {
|
|
1087
|
-
console.log(
|
|
1088
|
-
`Output: ${result.outputFiles[0]?.replace(/\/[^/]+$/, "/")}`
|
|
1089
|
-
);
|
|
1090
|
-
result.outputFiles.forEach((f) => console.log(` - ${f}`));
|
|
1091
|
-
}
|
|
1092
|
-
} catch (error) {
|
|
1093
|
-
spinner.fail(
|
|
1094
|
-
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1095
|
-
);
|
|
1096
|
-
process.exit(1);
|
|
1097
|
-
}
|
|
1530
|
+
iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
|
|
1531
|
+
animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
|
|
1532
|
+
debug: opts.debug ?? false
|
|
1533
|
+
})
|
|
1534
|
+
);
|
|
1535
|
+
await waitUntilExit();
|
|
1098
1536
|
});
|
|
1099
1537
|
program.parseAsync(process.argv).catch((error) => {
|
|
1100
1538
|
console.error("Fatal error:", error.message);
|