@lumy-pack/scene-sieve 0.0.5 → 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 +133 -20
- package/dist/commands/Sieve.d.ts +2 -0
- package/dist/core/analyzer.d.ts +9 -2
- package/dist/index.cjs +101 -14
- package/dist/index.mjs +101 -14
- package/dist/pipeline-worker.mjs +101 -14
- package/dist/types/index.d.ts +26 -0
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { join } from "path";
|
|
|
51
51
|
function getTempWorkspaceDir(sessionId) {
|
|
52
52
|
return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
|
|
53
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, 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;
|
|
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
55
|
var init_constants = __esm({
|
|
56
56
|
"src/constants.ts"() {
|
|
57
57
|
"use strict";
|
|
@@ -74,6 +74,7 @@ var init_constants = __esm({
|
|
|
74
74
|
];
|
|
75
75
|
SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
76
76
|
FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
77
|
+
FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
77
78
|
OPENCV_BATCH_SIZE = 10;
|
|
78
79
|
DBSCAN_ALPHA = 0.03;
|
|
79
80
|
DBSCAN_MIN_PTS = 4;
|
|
@@ -431,13 +432,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
431
432
|
}
|
|
432
433
|
async function analyzeFrames(ctx) {
|
|
433
434
|
const { frames } = ctx;
|
|
434
|
-
if (frames.length < 2) return [];
|
|
435
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
435
436
|
logger.debug(
|
|
436
437
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
437
438
|
);
|
|
438
439
|
const cvLib = await ensureOpenCV();
|
|
439
440
|
const edges = [];
|
|
440
|
-
const tracker = new IoUTracker(
|
|
441
|
+
const tracker = new IoUTracker(
|
|
442
|
+
ctx.options.fps,
|
|
443
|
+
ctx.options.iouThreshold,
|
|
444
|
+
ctx.options.animationThreshold
|
|
445
|
+
);
|
|
441
446
|
const scale = ctx.options.scale;
|
|
442
447
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
443
448
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -450,8 +455,11 @@ async function analyzeFrames(ctx) {
|
|
|
450
455
|
);
|
|
451
456
|
ctx.emitProgress(progress);
|
|
452
457
|
}
|
|
453
|
-
|
|
454
|
-
|
|
458
|
+
const animations = tracker.flushAndGetAnimations();
|
|
459
|
+
logger.debug(
|
|
460
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
461
|
+
);
|
|
462
|
+
return { edges, animations };
|
|
455
463
|
}
|
|
456
464
|
var OPENCV_INIT_TIMEOUT_MS, require2, cvReady, IoUTracker;
|
|
457
465
|
var init_analyzer = __esm({
|
|
@@ -464,7 +472,13 @@ var init_analyzer = __esm({
|
|
|
464
472
|
require2 = createRequire(import.meta.url);
|
|
465
473
|
cvReady = null;
|
|
466
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
|
+
}
|
|
467
480
|
regions = [];
|
|
481
|
+
extractedAnimations = [];
|
|
468
482
|
update(boxes, pairIndex) {
|
|
469
483
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
470
484
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -480,7 +494,7 @@ var init_analyzer = __esm({
|
|
|
480
494
|
bestRegionIdx = ri;
|
|
481
495
|
}
|
|
482
496
|
}
|
|
483
|
-
if (bestIoU >
|
|
497
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
484
498
|
const region = this.regions[bestRegionIdx];
|
|
485
499
|
const gap = pairIndex - region.lastSeen;
|
|
486
500
|
region.box = box;
|
|
@@ -488,13 +502,14 @@ var init_analyzer = __esm({
|
|
|
488
502
|
region.lastSeen = pairIndex;
|
|
489
503
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
490
504
|
matched.add(bestRegionIdx);
|
|
491
|
-
if (region.consecutiveCount >=
|
|
505
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
492
506
|
animationIndices.add(bi);
|
|
493
507
|
}
|
|
494
508
|
} else {
|
|
495
509
|
this.regions.push({
|
|
496
510
|
box,
|
|
497
511
|
consecutiveCount: 1,
|
|
512
|
+
firstSeen: pairIndex,
|
|
498
513
|
lastSeen: pairIndex,
|
|
499
514
|
weight: 1
|
|
500
515
|
});
|
|
@@ -506,17 +521,45 @@ var init_analyzer = __esm({
|
|
|
506
521
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
507
522
|
}
|
|
508
523
|
}
|
|
509
|
-
|
|
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
|
+
);
|
|
510
533
|
return animationIndices;
|
|
511
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
|
+
}
|
|
512
555
|
getAnimationWeight(boxIndex, boxes) {
|
|
513
556
|
if (boxIndex >= boxes.length) return 0;
|
|
514
557
|
const box = boxes[boxIndex];
|
|
515
558
|
let maxWeight = 0;
|
|
516
559
|
for (const region of this.regions) {
|
|
517
|
-
if (region.consecutiveCount >=
|
|
560
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
518
561
|
const iou = computeIoU(box, region.box);
|
|
519
|
-
if (iou >
|
|
562
|
+
if (iou > this.iouThreshold) {
|
|
520
563
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
521
564
|
}
|
|
522
565
|
}
|
|
@@ -607,7 +650,7 @@ async function extractFrames(ctx) {
|
|
|
607
650
|
return frames;
|
|
608
651
|
}
|
|
609
652
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
610
|
-
const outputPattern = join2(outputDir,
|
|
653
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
611
654
|
await execa(ffmpegPath, [
|
|
612
655
|
"-i",
|
|
613
656
|
inputPath,
|
|
@@ -675,13 +718,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
675
718
|
const outputPath = ctx.options.outputPath;
|
|
676
719
|
const quality = ctx.options.quality;
|
|
677
720
|
const outputFiles = [];
|
|
721
|
+
const framesMetadata = [];
|
|
722
|
+
const totalFramesCount = ctx.frames.length;
|
|
723
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
678
724
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
679
725
|
const frame = selectedFrames[i];
|
|
680
|
-
const
|
|
681
|
-
const destPath = join3(stagingDir,
|
|
726
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
727
|
+
const destPath = join3(stagingDir, fileName);
|
|
682
728
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
683
|
-
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
|
+
});
|
|
684
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"));
|
|
685
759
|
await ensureDir(join3(outputPath, ".."));
|
|
686
760
|
await rm(outputPath, { recursive: true, force: true });
|
|
687
761
|
await rename(stagingDir, outputPath);
|
|
@@ -769,6 +843,8 @@ function resolveOptions(options) {
|
|
|
769
843
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
770
844
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
771
845
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
846
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
847
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
772
848
|
debug: options.debug ?? false
|
|
773
849
|
};
|
|
774
850
|
}
|
|
@@ -1065,7 +1141,9 @@ async function runPipeline(options) {
|
|
|
1065
1141
|
}
|
|
1066
1142
|
ctx.emitProgress(100);
|
|
1067
1143
|
ctx.status = "ANALYZING";
|
|
1068
|
-
|
|
1144
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1145
|
+
ctx.graph = edges;
|
|
1146
|
+
ctx.animations = animations;
|
|
1069
1147
|
ctx.status = "PRUNING";
|
|
1070
1148
|
const survivingIds = pruneByThresholdWithCap(
|
|
1071
1149
|
ctx.graph,
|
|
@@ -1098,6 +1176,15 @@ async function runPipeline(options) {
|
|
|
1098
1176
|
prunedFramesCount: prunedFrames.length,
|
|
1099
1177
|
outputFiles,
|
|
1100
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
|
+
},
|
|
1101
1188
|
executionTimeMs: Date.now() - startTime
|
|
1102
1189
|
};
|
|
1103
1190
|
} catch (error) {
|
|
@@ -1280,6 +1367,8 @@ var SieveView = (props) => {
|
|
|
1280
1367
|
maxFrames: props.maxFrames,
|
|
1281
1368
|
scale: props.scale,
|
|
1282
1369
|
quality: props.quality,
|
|
1370
|
+
iouThreshold: props.iouThreshold,
|
|
1371
|
+
animationThreshold: props.animationThreshold,
|
|
1283
1372
|
debug: props.debug
|
|
1284
1373
|
},
|
|
1285
1374
|
(phase, percent) => {
|
|
@@ -1374,12 +1463,19 @@ var SieveView = (props) => {
|
|
|
1374
1463
|
"\u2713 Done",
|
|
1375
1464
|
" \u2014 ",
|
|
1376
1465
|
result.originalFramesCount,
|
|
1377
|
-
" frames \u2192
|
|
1466
|
+
" frames \u2192",
|
|
1467
|
+
" ",
|
|
1378
1468
|
result.prunedFramesCount,
|
|
1379
1469
|
" scenes (",
|
|
1380
1470
|
(result.executionTimeMs / 1e3).toFixed(1),
|
|
1381
1471
|
"s)"
|
|
1382
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
|
+
] }),
|
|
1383
1479
|
props.debug && result.outputFiles.length > 0 && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
|
|
1384
1480
|
/* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
|
|
1385
1481
|
"Output: ",
|
|
@@ -1395,17 +1491,32 @@ var SieveView = (props) => {
|
|
|
1395
1491
|
};
|
|
1396
1492
|
|
|
1397
1493
|
// src/cli.ts
|
|
1494
|
+
init_constants();
|
|
1398
1495
|
var require3 = createRequire2(import.meta.url);
|
|
1399
1496
|
var { version } = require3("../package.json");
|
|
1400
1497
|
var program = new Command();
|
|
1401
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(
|
|
1402
1499
|
"-t, --threshold <number>",
|
|
1403
1500
|
"Normalized threshold 0~1 (default: 0.5; keeps frames above ratio of max change)"
|
|
1404
|
-
).option("-o, --output <path>", "Output directory path").option("--fps <number>", "Max FPS for frame extraction",
|
|
1405
|
-
"--max-frames <number>",
|
|
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>",
|
|
1406
1503
|
"Max frames to extract (auto-reduces FPS for long videos)",
|
|
1407
|
-
|
|
1408
|
-
).option(
|
|
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) => {
|
|
1409
1520
|
const { waitUntilExit } = render(
|
|
1410
1521
|
React2.createElement(SieveView, {
|
|
1411
1522
|
input,
|
|
@@ -1416,6 +1527,8 @@ program.name("scene-sieve").description("Extract key frames from video and GIF f
|
|
|
1416
1527
|
maxFrames: parseInt(opts.maxFrames, 10),
|
|
1417
1528
|
scale: parseInt(opts.scale, 10),
|
|
1418
1529
|
quality: parseInt(opts.quality, 10),
|
|
1530
|
+
iouThreshold: opts.iouThreshold !== void 0 ? parseFloat(opts.iouThreshold) : void 0,
|
|
1531
|
+
animationThreshold: opts.animThreshold !== void 0 ? parseInt(opts.animThreshold, 10) : void 0,
|
|
1419
1532
|
debug: opts.debug ?? false
|
|
1420
1533
|
})
|
|
1421
1534
|
);
|
package/dist/commands/Sieve.d.ts
CHANGED
package/dist/core/analyzer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BoundingBox, ProcessContext
|
|
1
|
+
import type { AnalysisResult, AnimationMetadata, BoundingBox, ProcessContext } from '../types/index.js';
|
|
2
2
|
import type { Point2D } from './dbscan.js';
|
|
3
3
|
type CvLib = typeof import('@techstark/opencv-js');
|
|
4
4
|
export declare function preprocessFrame(framePath: string, scale: number): Promise<{
|
|
@@ -8,8 +8,15 @@ export declare function preprocessFrame(framePath: string, scale: number): Promi
|
|
|
8
8
|
}>;
|
|
9
9
|
export declare function computeIoU(a: BoundingBox, b: BoundingBox): number;
|
|
10
10
|
export declare class IoUTracker {
|
|
11
|
+
private fps;
|
|
12
|
+
private iouThreshold;
|
|
13
|
+
private animationThreshold;
|
|
11
14
|
private regions;
|
|
15
|
+
private extractedAnimations;
|
|
16
|
+
constructor(fps?: number, iouThreshold?: number, animationThreshold?: number);
|
|
12
17
|
update(boxes: BoundingBox[], pairIndex: number): Set<number>;
|
|
18
|
+
private collectAnimation;
|
|
19
|
+
flushAndGetAnimations(): AnimationMetadata[];
|
|
13
20
|
getAnimationWeight(boxIndex: number, boxes: BoundingBox[]): number;
|
|
14
21
|
}
|
|
15
22
|
export interface AKAZEResult {
|
|
@@ -51,5 +58,5 @@ export declare function computeInformationGain(clusters: BoundingBox[], clusterP
|
|
|
51
58
|
* 3. Spatio-temporal IoU Tracking
|
|
52
59
|
* 4. G(t) Information Gain Scoring
|
|
53
60
|
*/
|
|
54
|
-
export declare function analyzeFrames(ctx: ProcessContext): Promise<
|
|
61
|
+
export declare function analyzeFrames(ctx: ProcessContext): Promise<AnalysisResult>;
|
|
55
62
|
export {};
|
package/dist/index.cjs
CHANGED
|
@@ -97,6 +97,7 @@ var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
|
97
97
|
];
|
|
98
98
|
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
99
99
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
100
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
100
101
|
var OPENCV_BATCH_SIZE = 10;
|
|
101
102
|
var DBSCAN_ALPHA = 0.03;
|
|
102
103
|
var DBSCAN_MIN_PTS = 4;
|
|
@@ -236,7 +237,13 @@ function computeIoU(a, b) {
|
|
|
236
237
|
return union === 0 ? 0 : intersection / union;
|
|
237
238
|
}
|
|
238
239
|
var IoUTracker = class {
|
|
240
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
241
|
+
this.fps = fps;
|
|
242
|
+
this.iouThreshold = iouThreshold;
|
|
243
|
+
this.animationThreshold = animationThreshold;
|
|
244
|
+
}
|
|
239
245
|
regions = [];
|
|
246
|
+
extractedAnimations = [];
|
|
240
247
|
update(boxes, pairIndex) {
|
|
241
248
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
242
249
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -252,7 +259,7 @@ var IoUTracker = class {
|
|
|
252
259
|
bestRegionIdx = ri;
|
|
253
260
|
}
|
|
254
261
|
}
|
|
255
|
-
if (bestIoU >
|
|
262
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
256
263
|
const region = this.regions[bestRegionIdx];
|
|
257
264
|
const gap = pairIndex - region.lastSeen;
|
|
258
265
|
region.box = box;
|
|
@@ -260,13 +267,14 @@ var IoUTracker = class {
|
|
|
260
267
|
region.lastSeen = pairIndex;
|
|
261
268
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
262
269
|
matched.add(bestRegionIdx);
|
|
263
|
-
if (region.consecutiveCount >=
|
|
270
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
264
271
|
animationIndices.add(bi);
|
|
265
272
|
}
|
|
266
273
|
} else {
|
|
267
274
|
this.regions.push({
|
|
268
275
|
box,
|
|
269
276
|
consecutiveCount: 1,
|
|
277
|
+
firstSeen: pairIndex,
|
|
270
278
|
lastSeen: pairIndex,
|
|
271
279
|
weight: 1
|
|
272
280
|
});
|
|
@@ -278,17 +286,45 @@ var IoUTracker = class {
|
|
|
278
286
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
279
287
|
}
|
|
280
288
|
}
|
|
281
|
-
|
|
289
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
290
|
+
const region = this.regions[i];
|
|
291
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
292
|
+
this.collectAnimation(region);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.regions = this.regions.filter(
|
|
296
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
297
|
+
);
|
|
282
298
|
return animationIndices;
|
|
283
299
|
}
|
|
300
|
+
collectAnimation(region) {
|
|
301
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
302
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
303
|
+
this.extractedAnimations.push({
|
|
304
|
+
type: "loading_spinner",
|
|
305
|
+
// 기본값으로 loading_spinner 사용
|
|
306
|
+
boundingBox: region.box,
|
|
307
|
+
startFrameId: region.firstSeen,
|
|
308
|
+
endFrameId: region.lastSeen,
|
|
309
|
+
durationMs
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
flushAndGetAnimations() {
|
|
314
|
+
for (const region of this.regions) {
|
|
315
|
+
this.collectAnimation(region);
|
|
316
|
+
}
|
|
317
|
+
this.regions = [];
|
|
318
|
+
return this.extractedAnimations;
|
|
319
|
+
}
|
|
284
320
|
getAnimationWeight(boxIndex, boxes) {
|
|
285
321
|
if (boxIndex >= boxes.length) return 0;
|
|
286
322
|
const box = boxes[boxIndex];
|
|
287
323
|
let maxWeight = 0;
|
|
288
324
|
for (const region of this.regions) {
|
|
289
|
-
if (region.consecutiveCount >=
|
|
325
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
290
326
|
const iou = computeIoU(box, region.box);
|
|
291
|
-
if (iou >
|
|
327
|
+
if (iou > this.iouThreshold) {
|
|
292
328
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
293
329
|
}
|
|
294
330
|
}
|
|
@@ -510,13 +546,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
510
546
|
}
|
|
511
547
|
async function analyzeFrames(ctx) {
|
|
512
548
|
const { frames } = ctx;
|
|
513
|
-
if (frames.length < 2) return [];
|
|
549
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
514
550
|
logger.debug(
|
|
515
551
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
516
552
|
);
|
|
517
553
|
const cvLib = await ensureOpenCV();
|
|
518
554
|
const edges = [];
|
|
519
|
-
const tracker = new IoUTracker(
|
|
555
|
+
const tracker = new IoUTracker(
|
|
556
|
+
ctx.options.fps,
|
|
557
|
+
ctx.options.iouThreshold,
|
|
558
|
+
ctx.options.animationThreshold
|
|
559
|
+
);
|
|
520
560
|
const scale = ctx.options.scale;
|
|
521
561
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
522
562
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -529,8 +569,11 @@ async function analyzeFrames(ctx) {
|
|
|
529
569
|
);
|
|
530
570
|
ctx.emitProgress(progress);
|
|
531
571
|
}
|
|
532
|
-
|
|
533
|
-
|
|
572
|
+
const animations = tracker.flushAndGetAnimations();
|
|
573
|
+
logger.debug(
|
|
574
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
575
|
+
);
|
|
576
|
+
return { edges, animations };
|
|
534
577
|
}
|
|
535
578
|
|
|
536
579
|
// src/core/extractor.ts
|
|
@@ -610,7 +653,7 @@ async function extractFrames(ctx) {
|
|
|
610
653
|
return frames;
|
|
611
654
|
}
|
|
612
655
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
613
|
-
const outputPattern = (0, import_node_path3.join)(outputDir,
|
|
656
|
+
const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
|
|
614
657
|
await (0, import_execa.execa)(import_ffmpeg_static.default, [
|
|
615
658
|
"-i",
|
|
616
659
|
inputPath,
|
|
@@ -673,13 +716,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
673
716
|
const outputPath = ctx.options.outputPath;
|
|
674
717
|
const quality = ctx.options.quality;
|
|
675
718
|
const outputFiles = [];
|
|
719
|
+
const framesMetadata = [];
|
|
720
|
+
const totalFramesCount = ctx.frames.length;
|
|
721
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
676
722
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
677
723
|
const frame = selectedFrames[i];
|
|
678
|
-
const
|
|
679
|
-
const destPath = (0, import_node_path4.join)(stagingDir,
|
|
724
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
725
|
+
const destPath = (0, import_node_path4.join)(stagingDir, fileName);
|
|
680
726
|
await (0, import_sharp2.default)(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
681
|
-
outputFiles.push((0, import_node_path4.join)(outputPath,
|
|
727
|
+
outputFiles.push((0, import_node_path4.join)(outputPath, fileName));
|
|
728
|
+
framesMetadata.push({
|
|
729
|
+
step: i + 1,
|
|
730
|
+
fileName,
|
|
731
|
+
frameId: frame.id + 1,
|
|
732
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
733
|
+
});
|
|
682
734
|
}
|
|
735
|
+
const metadata = {
|
|
736
|
+
video: {
|
|
737
|
+
originalDurationMs: Math.round(
|
|
738
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
739
|
+
),
|
|
740
|
+
fps: ctx.options.fps,
|
|
741
|
+
resolution: {
|
|
742
|
+
width: ctx.options.scale,
|
|
743
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
frames: framesMetadata,
|
|
747
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
748
|
+
...anim,
|
|
749
|
+
startFrameId: anim.startFrameId + 1,
|
|
750
|
+
endFrameId: anim.endFrameId + 1,
|
|
751
|
+
durationMs: Math.round(anim.durationMs)
|
|
752
|
+
}))
|
|
753
|
+
};
|
|
754
|
+
const metadataPath = (0, import_node_path4.join)(stagingDir, ".metadata.json");
|
|
755
|
+
await (0, import_promises3.writeFile)(metadataPath, JSON.stringify(metadata, null, 2));
|
|
756
|
+
outputFiles.push((0, import_node_path4.join)(outputPath, ".metadata.json"));
|
|
683
757
|
await ensureDir((0, import_node_path4.join)(outputPath, ".."));
|
|
684
758
|
await (0, import_promises3.rm)(outputPath, { recursive: true, force: true });
|
|
685
759
|
await (0, import_promises3.rename)(stagingDir, outputPath);
|
|
@@ -743,6 +817,8 @@ function resolveOptions(options) {
|
|
|
743
817
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
744
818
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
745
819
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
820
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
821
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
746
822
|
debug: options.debug ?? false
|
|
747
823
|
};
|
|
748
824
|
}
|
|
@@ -1013,7 +1089,9 @@ async function runPipeline(options) {
|
|
|
1013
1089
|
}
|
|
1014
1090
|
ctx.emitProgress(100);
|
|
1015
1091
|
ctx.status = "ANALYZING";
|
|
1016
|
-
|
|
1092
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1093
|
+
ctx.graph = edges;
|
|
1094
|
+
ctx.animations = animations;
|
|
1017
1095
|
ctx.status = "PRUNING";
|
|
1018
1096
|
const survivingIds = pruneByThresholdWithCap(
|
|
1019
1097
|
ctx.graph,
|
|
@@ -1046,6 +1124,15 @@ async function runPipeline(options) {
|
|
|
1046
1124
|
prunedFramesCount: prunedFrames.length,
|
|
1047
1125
|
outputFiles,
|
|
1048
1126
|
outputBuffers,
|
|
1127
|
+
animations: ctx.animations,
|
|
1128
|
+
video: {
|
|
1129
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1130
|
+
fps: ctx.options.fps,
|
|
1131
|
+
resolution: {
|
|
1132
|
+
width: ctx.options.scale,
|
|
1133
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1049
1136
|
executionTimeMs: Date.now() - startTime
|
|
1050
1137
|
};
|
|
1051
1138
|
} catch (error) {
|
package/dist/index.mjs
CHANGED
|
@@ -57,6 +57,7 @@ var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
|
57
57
|
];
|
|
58
58
|
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
59
59
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
60
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
60
61
|
var OPENCV_BATCH_SIZE = 10;
|
|
61
62
|
var DBSCAN_ALPHA = 0.03;
|
|
62
63
|
var DBSCAN_MIN_PTS = 4;
|
|
@@ -196,7 +197,13 @@ function computeIoU(a, b) {
|
|
|
196
197
|
return union === 0 ? 0 : intersection / union;
|
|
197
198
|
}
|
|
198
199
|
var IoUTracker = class {
|
|
200
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
201
|
+
this.fps = fps;
|
|
202
|
+
this.iouThreshold = iouThreshold;
|
|
203
|
+
this.animationThreshold = animationThreshold;
|
|
204
|
+
}
|
|
199
205
|
regions = [];
|
|
206
|
+
extractedAnimations = [];
|
|
200
207
|
update(boxes, pairIndex) {
|
|
201
208
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
202
209
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -212,7 +219,7 @@ var IoUTracker = class {
|
|
|
212
219
|
bestRegionIdx = ri;
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
|
-
if (bestIoU >
|
|
222
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
216
223
|
const region = this.regions[bestRegionIdx];
|
|
217
224
|
const gap = pairIndex - region.lastSeen;
|
|
218
225
|
region.box = box;
|
|
@@ -220,13 +227,14 @@ var IoUTracker = class {
|
|
|
220
227
|
region.lastSeen = pairIndex;
|
|
221
228
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
222
229
|
matched.add(bestRegionIdx);
|
|
223
|
-
if (region.consecutiveCount >=
|
|
230
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
224
231
|
animationIndices.add(bi);
|
|
225
232
|
}
|
|
226
233
|
} else {
|
|
227
234
|
this.regions.push({
|
|
228
235
|
box,
|
|
229
236
|
consecutiveCount: 1,
|
|
237
|
+
firstSeen: pairIndex,
|
|
230
238
|
lastSeen: pairIndex,
|
|
231
239
|
weight: 1
|
|
232
240
|
});
|
|
@@ -238,17 +246,45 @@ var IoUTracker = class {
|
|
|
238
246
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
|
-
|
|
249
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
250
|
+
const region = this.regions[i];
|
|
251
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
252
|
+
this.collectAnimation(region);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
this.regions = this.regions.filter(
|
|
256
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
257
|
+
);
|
|
242
258
|
return animationIndices;
|
|
243
259
|
}
|
|
260
|
+
collectAnimation(region) {
|
|
261
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
262
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
263
|
+
this.extractedAnimations.push({
|
|
264
|
+
type: "loading_spinner",
|
|
265
|
+
// 기본값으로 loading_spinner 사용
|
|
266
|
+
boundingBox: region.box,
|
|
267
|
+
startFrameId: region.firstSeen,
|
|
268
|
+
endFrameId: region.lastSeen,
|
|
269
|
+
durationMs
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
flushAndGetAnimations() {
|
|
274
|
+
for (const region of this.regions) {
|
|
275
|
+
this.collectAnimation(region);
|
|
276
|
+
}
|
|
277
|
+
this.regions = [];
|
|
278
|
+
return this.extractedAnimations;
|
|
279
|
+
}
|
|
244
280
|
getAnimationWeight(boxIndex, boxes) {
|
|
245
281
|
if (boxIndex >= boxes.length) return 0;
|
|
246
282
|
const box = boxes[boxIndex];
|
|
247
283
|
let maxWeight = 0;
|
|
248
284
|
for (const region of this.regions) {
|
|
249
|
-
if (region.consecutiveCount >=
|
|
285
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
250
286
|
const iou = computeIoU(box, region.box);
|
|
251
|
-
if (iou >
|
|
287
|
+
if (iou > this.iouThreshold) {
|
|
252
288
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
253
289
|
}
|
|
254
290
|
}
|
|
@@ -470,13 +506,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
470
506
|
}
|
|
471
507
|
async function analyzeFrames(ctx) {
|
|
472
508
|
const { frames } = ctx;
|
|
473
|
-
if (frames.length < 2) return [];
|
|
509
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
474
510
|
logger.debug(
|
|
475
511
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
476
512
|
);
|
|
477
513
|
const cvLib = await ensureOpenCV();
|
|
478
514
|
const edges = [];
|
|
479
|
-
const tracker = new IoUTracker(
|
|
515
|
+
const tracker = new IoUTracker(
|
|
516
|
+
ctx.options.fps,
|
|
517
|
+
ctx.options.iouThreshold,
|
|
518
|
+
ctx.options.animationThreshold
|
|
519
|
+
);
|
|
480
520
|
const scale = ctx.options.scale;
|
|
481
521
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
482
522
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -489,8 +529,11 @@ async function analyzeFrames(ctx) {
|
|
|
489
529
|
);
|
|
490
530
|
ctx.emitProgress(progress);
|
|
491
531
|
}
|
|
492
|
-
|
|
493
|
-
|
|
532
|
+
const animations = tracker.flushAndGetAnimations();
|
|
533
|
+
logger.debug(
|
|
534
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
535
|
+
);
|
|
536
|
+
return { edges, animations };
|
|
494
537
|
}
|
|
495
538
|
|
|
496
539
|
// src/core/extractor.ts
|
|
@@ -570,7 +613,7 @@ async function extractFrames(ctx) {
|
|
|
570
613
|
return frames;
|
|
571
614
|
}
|
|
572
615
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
573
|
-
const outputPattern = join2(outputDir,
|
|
616
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
574
617
|
await execa(ffmpegPath, [
|
|
575
618
|
"-i",
|
|
576
619
|
inputPath,
|
|
@@ -633,13 +676,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
633
676
|
const outputPath = ctx.options.outputPath;
|
|
634
677
|
const quality = ctx.options.quality;
|
|
635
678
|
const outputFiles = [];
|
|
679
|
+
const framesMetadata = [];
|
|
680
|
+
const totalFramesCount = ctx.frames.length;
|
|
681
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
636
682
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
637
683
|
const frame = selectedFrames[i];
|
|
638
|
-
const
|
|
639
|
-
const destPath = join3(stagingDir,
|
|
684
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
685
|
+
const destPath = join3(stagingDir, fileName);
|
|
640
686
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
641
|
-
outputFiles.push(join3(outputPath,
|
|
687
|
+
outputFiles.push(join3(outputPath, fileName));
|
|
688
|
+
framesMetadata.push({
|
|
689
|
+
step: i + 1,
|
|
690
|
+
fileName,
|
|
691
|
+
frameId: frame.id + 1,
|
|
692
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
693
|
+
});
|
|
642
694
|
}
|
|
695
|
+
const metadata = {
|
|
696
|
+
video: {
|
|
697
|
+
originalDurationMs: Math.round(
|
|
698
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
699
|
+
),
|
|
700
|
+
fps: ctx.options.fps,
|
|
701
|
+
resolution: {
|
|
702
|
+
width: ctx.options.scale,
|
|
703
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
frames: framesMetadata,
|
|
707
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
708
|
+
...anim,
|
|
709
|
+
startFrameId: anim.startFrameId + 1,
|
|
710
|
+
endFrameId: anim.endFrameId + 1,
|
|
711
|
+
durationMs: Math.round(anim.durationMs)
|
|
712
|
+
}))
|
|
713
|
+
};
|
|
714
|
+
const metadataPath = join3(stagingDir, ".metadata.json");
|
|
715
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
716
|
+
outputFiles.push(join3(outputPath, ".metadata.json"));
|
|
643
717
|
await ensureDir(join3(outputPath, ".."));
|
|
644
718
|
await rm(outputPath, { recursive: true, force: true });
|
|
645
719
|
await rename(stagingDir, outputPath);
|
|
@@ -703,6 +777,8 @@ function resolveOptions(options) {
|
|
|
703
777
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
704
778
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
705
779
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
780
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
781
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
706
782
|
debug: options.debug ?? false
|
|
707
783
|
};
|
|
708
784
|
}
|
|
@@ -973,7 +1049,9 @@ async function runPipeline(options) {
|
|
|
973
1049
|
}
|
|
974
1050
|
ctx.emitProgress(100);
|
|
975
1051
|
ctx.status = "ANALYZING";
|
|
976
|
-
|
|
1052
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1053
|
+
ctx.graph = edges;
|
|
1054
|
+
ctx.animations = animations;
|
|
977
1055
|
ctx.status = "PRUNING";
|
|
978
1056
|
const survivingIds = pruneByThresholdWithCap(
|
|
979
1057
|
ctx.graph,
|
|
@@ -1006,6 +1084,15 @@ async function runPipeline(options) {
|
|
|
1006
1084
|
prunedFramesCount: prunedFrames.length,
|
|
1007
1085
|
outputFiles,
|
|
1008
1086
|
outputBuffers,
|
|
1087
|
+
animations: ctx.animations,
|
|
1088
|
+
video: {
|
|
1089
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1090
|
+
fps: ctx.options.fps,
|
|
1091
|
+
resolution: {
|
|
1092
|
+
width: ctx.options.scale,
|
|
1093
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1009
1096
|
executionTimeMs: Date.now() - startTime
|
|
1010
1097
|
};
|
|
1011
1098
|
} catch (error) {
|
package/dist/pipeline-worker.mjs
CHANGED
|
@@ -60,6 +60,7 @@ var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
|
60
60
|
];
|
|
61
61
|
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
62
62
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
63
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
63
64
|
var OPENCV_BATCH_SIZE = 10;
|
|
64
65
|
var DBSCAN_ALPHA = 0.03;
|
|
65
66
|
var DBSCAN_MIN_PTS = 4;
|
|
@@ -199,7 +200,13 @@ function computeIoU(a, b) {
|
|
|
199
200
|
return union === 0 ? 0 : intersection / union;
|
|
200
201
|
}
|
|
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
|
+
}
|
|
202
208
|
regions = [];
|
|
209
|
+
extractedAnimations = [];
|
|
203
210
|
update(boxes, pairIndex) {
|
|
204
211
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
205
212
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -215,7 +222,7 @@ var IoUTracker = class {
|
|
|
215
222
|
bestRegionIdx = ri;
|
|
216
223
|
}
|
|
217
224
|
}
|
|
218
|
-
if (bestIoU >
|
|
225
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
219
226
|
const region = this.regions[bestRegionIdx];
|
|
220
227
|
const gap = pairIndex - region.lastSeen;
|
|
221
228
|
region.box = box;
|
|
@@ -223,13 +230,14 @@ var IoUTracker = class {
|
|
|
223
230
|
region.lastSeen = pairIndex;
|
|
224
231
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
225
232
|
matched.add(bestRegionIdx);
|
|
226
|
-
if (region.consecutiveCount >=
|
|
233
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
227
234
|
animationIndices.add(bi);
|
|
228
235
|
}
|
|
229
236
|
} else {
|
|
230
237
|
this.regions.push({
|
|
231
238
|
box,
|
|
232
239
|
consecutiveCount: 1,
|
|
240
|
+
firstSeen: pairIndex,
|
|
233
241
|
lastSeen: pairIndex,
|
|
234
242
|
weight: 1
|
|
235
243
|
});
|
|
@@ -241,17 +249,45 @@ var IoUTracker = class {
|
|
|
241
249
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
242
250
|
}
|
|
243
251
|
}
|
|
244
|
-
|
|
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
|
+
);
|
|
245
261
|
return animationIndices;
|
|
246
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
|
+
}
|
|
247
283
|
getAnimationWeight(boxIndex, boxes) {
|
|
248
284
|
if (boxIndex >= boxes.length) return 0;
|
|
249
285
|
const box = boxes[boxIndex];
|
|
250
286
|
let maxWeight = 0;
|
|
251
287
|
for (const region of this.regions) {
|
|
252
|
-
if (region.consecutiveCount >=
|
|
288
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
253
289
|
const iou = computeIoU(box, region.box);
|
|
254
|
-
if (iou >
|
|
290
|
+
if (iou > this.iouThreshold) {
|
|
255
291
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
256
292
|
}
|
|
257
293
|
}
|
|
@@ -473,13 +509,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
473
509
|
}
|
|
474
510
|
async function analyzeFrames(ctx) {
|
|
475
511
|
const { frames } = ctx;
|
|
476
|
-
if (frames.length < 2) return [];
|
|
512
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
477
513
|
logger.debug(
|
|
478
514
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
479
515
|
);
|
|
480
516
|
const cvLib = await ensureOpenCV();
|
|
481
517
|
const edges = [];
|
|
482
|
-
const tracker = new IoUTracker(
|
|
518
|
+
const tracker = new IoUTracker(
|
|
519
|
+
ctx.options.fps,
|
|
520
|
+
ctx.options.iouThreshold,
|
|
521
|
+
ctx.options.animationThreshold
|
|
522
|
+
);
|
|
483
523
|
const scale = ctx.options.scale;
|
|
484
524
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
485
525
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -492,8 +532,11 @@ async function analyzeFrames(ctx) {
|
|
|
492
532
|
);
|
|
493
533
|
ctx.emitProgress(progress);
|
|
494
534
|
}
|
|
495
|
-
|
|
496
|
-
|
|
535
|
+
const animations = tracker.flushAndGetAnimations();
|
|
536
|
+
logger.debug(
|
|
537
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
538
|
+
);
|
|
539
|
+
return { edges, animations };
|
|
497
540
|
}
|
|
498
541
|
|
|
499
542
|
// src/core/extractor.ts
|
|
@@ -573,7 +616,7 @@ async function extractFrames(ctx) {
|
|
|
573
616
|
return frames;
|
|
574
617
|
}
|
|
575
618
|
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
576
|
-
const outputPattern = join2(outputDir,
|
|
619
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
577
620
|
await execa(ffmpegPath, [
|
|
578
621
|
"-i",
|
|
579
622
|
inputPath,
|
|
@@ -636,13 +679,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
636
679
|
const outputPath = ctx.options.outputPath;
|
|
637
680
|
const quality = ctx.options.quality;
|
|
638
681
|
const outputFiles = [];
|
|
682
|
+
const framesMetadata = [];
|
|
683
|
+
const totalFramesCount = ctx.frames.length;
|
|
684
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
639
685
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
640
686
|
const frame = selectedFrames[i];
|
|
641
|
-
const
|
|
642
|
-
const destPath = join3(stagingDir,
|
|
687
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
688
|
+
const destPath = join3(stagingDir, fileName);
|
|
643
689
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
644
|
-
outputFiles.push(join3(outputPath,
|
|
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
|
+
});
|
|
645
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"));
|
|
646
720
|
await ensureDir(join3(outputPath, ".."));
|
|
647
721
|
await rm(outputPath, { recursive: true, force: true });
|
|
648
722
|
await rename(stagingDir, outputPath);
|
|
@@ -706,6 +780,8 @@ function resolveOptions(options2) {
|
|
|
706
780
|
maxFrames: options2.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
707
781
|
scale: options2.scale ?? DEFAULT_SCALE,
|
|
708
782
|
quality: options2.quality ?? DEFAULT_QUALITY,
|
|
783
|
+
iouThreshold: options2.iouThreshold ?? IOU_THRESHOLD,
|
|
784
|
+
animationThreshold: options2.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
709
785
|
debug: options2.debug ?? false
|
|
710
786
|
};
|
|
711
787
|
}
|
|
@@ -976,7 +1052,9 @@ async function runPipeline(options2) {
|
|
|
976
1052
|
}
|
|
977
1053
|
ctx.emitProgress(100);
|
|
978
1054
|
ctx.status = "ANALYZING";
|
|
979
|
-
|
|
1055
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1056
|
+
ctx.graph = edges;
|
|
1057
|
+
ctx.animations = animations;
|
|
980
1058
|
ctx.status = "PRUNING";
|
|
981
1059
|
const survivingIds = pruneByThresholdWithCap(
|
|
982
1060
|
ctx.graph,
|
|
@@ -1009,6 +1087,15 @@ async function runPipeline(options2) {
|
|
|
1009
1087
|
prunedFramesCount: prunedFrames.length,
|
|
1010
1088
|
outputFiles,
|
|
1011
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
|
+
},
|
|
1012
1099
|
executionTimeMs: Date.now() - startTime
|
|
1013
1100
|
};
|
|
1014
1101
|
} catch (error) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface SieveOptionsBase {
|
|
|
17
17
|
maxFrames?: number;
|
|
18
18
|
scale?: number;
|
|
19
19
|
quality?: number;
|
|
20
|
+
iouThreshold?: number;
|
|
21
|
+
animationThreshold?: number;
|
|
20
22
|
debug?: boolean;
|
|
21
23
|
onProgress?: (phase: ProgressPhase, percent: number) => void;
|
|
22
24
|
}
|
|
@@ -32,6 +34,8 @@ export interface ResolvedOptions {
|
|
|
32
34
|
maxFrames: number;
|
|
33
35
|
scale: number;
|
|
34
36
|
quality: number;
|
|
37
|
+
iouThreshold: number;
|
|
38
|
+
animationThreshold: number;
|
|
35
39
|
debug: boolean;
|
|
36
40
|
}
|
|
37
41
|
export interface SieveResult {
|
|
@@ -40,8 +44,25 @@ export interface SieveResult {
|
|
|
40
44
|
prunedFramesCount: number;
|
|
41
45
|
outputFiles: string[];
|
|
42
46
|
outputBuffers?: Buffer[];
|
|
47
|
+
animations?: AnimationMetadata[];
|
|
48
|
+
video?: VideoMetadata;
|
|
43
49
|
executionTimeMs: number;
|
|
44
50
|
}
|
|
51
|
+
export interface AnimationMetadata {
|
|
52
|
+
type: string;
|
|
53
|
+
boundingBox: BoundingBox;
|
|
54
|
+
startFrameId: number;
|
|
55
|
+
endFrameId: number;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
}
|
|
58
|
+
export interface VideoMetadata {
|
|
59
|
+
originalDurationMs: number;
|
|
60
|
+
fps: number;
|
|
61
|
+
resolution: {
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
45
66
|
export interface FrameNode {
|
|
46
67
|
id: number;
|
|
47
68
|
timestamp: number;
|
|
@@ -75,7 +96,12 @@ export interface ProcessContext {
|
|
|
75
96
|
workspacePath: string;
|
|
76
97
|
frames: FrameNode[];
|
|
77
98
|
graph: ScoreEdge[];
|
|
99
|
+
animations?: AnimationMetadata[];
|
|
78
100
|
status: 'INIT' | ProgressPhase | 'SUCCESS' | 'FAILED';
|
|
79
101
|
emitProgress: (percent: number) => void;
|
|
80
102
|
error?: Error;
|
|
81
103
|
}
|
|
104
|
+
export interface AnalysisResult {
|
|
105
|
+
edges: ScoreEdge[];
|
|
106
|
+
animations: AnimationMetadata[];
|
|
107
|
+
}
|