@lumy-pack/scene-sieve 0.0.5 → 0.0.7
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/README.md +64 -19
- package/dist/cli.mjs +164 -55
- package/dist/commands/Sieve.d.ts +2 -0
- package/dist/constants.d.ts +0 -2
- package/dist/core/analyzer.d.ts +9 -2
- package/dist/index.cjs +132 -49
- package/dist/index.mjs +132 -49
- package/dist/pipeline-worker.mjs +132 -49
- package/dist/types/index.d.ts +26 -0
- package/dist/utils/paths.d.ts +0 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -88,15 +88,8 @@ var DEFAULT_MAX_FRAMES = 300;
|
|
|
88
88
|
var NORMALIZATION_PERCENTILE = 0.9;
|
|
89
89
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
90
90
|
var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
|
|
91
|
-
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
92
|
-
".mp4",
|
|
93
|
-
".mov",
|
|
94
|
-
".avi",
|
|
95
|
-
".mkv",
|
|
96
|
-
".webm"
|
|
97
|
-
];
|
|
98
|
-
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
99
91
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
92
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
100
93
|
var OPENCV_BATCH_SIZE = 10;
|
|
101
94
|
var DBSCAN_ALPHA = 0.03;
|
|
102
95
|
var DBSCAN_MIN_PTS = 4;
|
|
@@ -236,7 +229,13 @@ function computeIoU(a, b) {
|
|
|
236
229
|
return union === 0 ? 0 : intersection / union;
|
|
237
230
|
}
|
|
238
231
|
var IoUTracker = class {
|
|
232
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
233
|
+
this.fps = fps;
|
|
234
|
+
this.iouThreshold = iouThreshold;
|
|
235
|
+
this.animationThreshold = animationThreshold;
|
|
236
|
+
}
|
|
239
237
|
regions = [];
|
|
238
|
+
extractedAnimations = [];
|
|
240
239
|
update(boxes, pairIndex) {
|
|
241
240
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
242
241
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -252,7 +251,7 @@ var IoUTracker = class {
|
|
|
252
251
|
bestRegionIdx = ri;
|
|
253
252
|
}
|
|
254
253
|
}
|
|
255
|
-
if (bestIoU >
|
|
254
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
256
255
|
const region = this.regions[bestRegionIdx];
|
|
257
256
|
const gap = pairIndex - region.lastSeen;
|
|
258
257
|
region.box = box;
|
|
@@ -260,13 +259,14 @@ var IoUTracker = class {
|
|
|
260
259
|
region.lastSeen = pairIndex;
|
|
261
260
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
262
261
|
matched.add(bestRegionIdx);
|
|
263
|
-
if (region.consecutiveCount >=
|
|
262
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
264
263
|
animationIndices.add(bi);
|
|
265
264
|
}
|
|
266
265
|
} else {
|
|
267
266
|
this.regions.push({
|
|
268
267
|
box,
|
|
269
268
|
consecutiveCount: 1,
|
|
269
|
+
firstSeen: pairIndex,
|
|
270
270
|
lastSeen: pairIndex,
|
|
271
271
|
weight: 1
|
|
272
272
|
});
|
|
@@ -278,17 +278,45 @@ var IoUTracker = class {
|
|
|
278
278
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
|
-
|
|
281
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
282
|
+
const region = this.regions[i];
|
|
283
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
284
|
+
this.collectAnimation(region);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
this.regions = this.regions.filter(
|
|
288
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
289
|
+
);
|
|
282
290
|
return animationIndices;
|
|
283
291
|
}
|
|
292
|
+
collectAnimation(region) {
|
|
293
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
294
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
295
|
+
this.extractedAnimations.push({
|
|
296
|
+
type: "loading_spinner",
|
|
297
|
+
// 기본값으로 loading_spinner 사용
|
|
298
|
+
boundingBox: region.box,
|
|
299
|
+
startFrameId: region.firstSeen,
|
|
300
|
+
endFrameId: region.lastSeen,
|
|
301
|
+
durationMs
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
flushAndGetAnimations() {
|
|
306
|
+
for (const region of this.regions) {
|
|
307
|
+
this.collectAnimation(region);
|
|
308
|
+
}
|
|
309
|
+
this.regions = [];
|
|
310
|
+
return this.extractedAnimations;
|
|
311
|
+
}
|
|
284
312
|
getAnimationWeight(boxIndex, boxes) {
|
|
285
313
|
if (boxIndex >= boxes.length) return 0;
|
|
286
314
|
const box = boxes[boxIndex];
|
|
287
315
|
let maxWeight = 0;
|
|
288
316
|
for (const region of this.regions) {
|
|
289
|
-
if (region.consecutiveCount >=
|
|
317
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
290
318
|
const iou = computeIoU(box, region.box);
|
|
291
|
-
if (iou >
|
|
319
|
+
if (iou > this.iouThreshold) {
|
|
292
320
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
293
321
|
}
|
|
294
322
|
}
|
|
@@ -510,13 +538,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
510
538
|
}
|
|
511
539
|
async function analyzeFrames(ctx) {
|
|
512
540
|
const { frames } = ctx;
|
|
513
|
-
if (frames.length < 2) return [];
|
|
541
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
514
542
|
logger.debug(
|
|
515
543
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
516
544
|
);
|
|
517
545
|
const cvLib = await ensureOpenCV();
|
|
518
546
|
const edges = [];
|
|
519
|
-
const tracker = new IoUTracker(
|
|
547
|
+
const tracker = new IoUTracker(
|
|
548
|
+
ctx.options.fps,
|
|
549
|
+
ctx.options.iouThreshold,
|
|
550
|
+
ctx.options.animationThreshold
|
|
551
|
+
);
|
|
520
552
|
const scale = ctx.options.scale;
|
|
521
553
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
522
554
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -529,8 +561,11 @@ async function analyzeFrames(ctx) {
|
|
|
529
561
|
);
|
|
530
562
|
ctx.emitProgress(progress);
|
|
531
563
|
}
|
|
532
|
-
|
|
533
|
-
|
|
564
|
+
const animations = tracker.flushAndGetAnimations();
|
|
565
|
+
logger.debug(
|
|
566
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
567
|
+
);
|
|
568
|
+
return { edges, animations };
|
|
534
569
|
}
|
|
535
570
|
|
|
536
571
|
// src/core/extractor.ts
|
|
@@ -570,9 +605,6 @@ function deriveOutputPath(inputPath) {
|
|
|
570
605
|
const name = (0, import_node_path2.basename)(inputPath, (0, import_node_path2.extname)(inputPath));
|
|
571
606
|
return (0, import_node_path2.resolve)(dir, `${name}_scenes`);
|
|
572
607
|
}
|
|
573
|
-
function isSupportedFile(filePath, extensions) {
|
|
574
|
-
return extensions.includes((0, import_node_path2.extname)(filePath).toLowerCase());
|
|
575
|
-
}
|
|
576
608
|
|
|
577
609
|
// src/core/extractor.ts
|
|
578
610
|
async function extractFrames(ctx) {
|
|
@@ -585,32 +617,47 @@ async function extractFrames(ctx) {
|
|
|
585
617
|
if (!exists) {
|
|
586
618
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
587
619
|
}
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (!
|
|
593
|
-
throw new Error(`
|
|
620
|
+
const metadata = await getVideoMetadata(inputPath).catch((err) => {
|
|
621
|
+
logger.debug(`ffprobe failed: ${err.message}`);
|
|
622
|
+
return null;
|
|
623
|
+
});
|
|
624
|
+
if (!metadata || !metadata.format) {
|
|
625
|
+
throw new Error(`Could not read file metadata: ${inputPath}`);
|
|
594
626
|
}
|
|
595
|
-
|
|
627
|
+
const formatName = metadata.format.format_name ?? "";
|
|
628
|
+
const duration = parseFloat(metadata.format.duration ?? "0");
|
|
629
|
+
const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
|
|
630
|
+
if (!hasVideoStream) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`No video stream found in file: ${inputPath} (detected format: ${formatName})`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
logger.debug(
|
|
636
|
+
`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
|
|
637
|
+
);
|
|
596
638
|
await ensureDir(framesDir);
|
|
597
639
|
let effectiveFps = fps;
|
|
598
|
-
const duration = await getVideoDuration(inputPath).catch(() => 0);
|
|
599
640
|
if (duration > 0) {
|
|
600
641
|
const fpsCap = maxFrames / duration;
|
|
601
642
|
effectiveFps = Math.min(fps, fpsCap);
|
|
602
643
|
effectiveFps = Math.max(0.5, effectiveFps);
|
|
603
644
|
logger.debug(
|
|
604
|
-
`
|
|
645
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
605
646
|
);
|
|
606
647
|
}
|
|
607
|
-
const frames = await extractByFps(
|
|
648
|
+
const frames = await extractByFps(
|
|
649
|
+
inputPath,
|
|
650
|
+
framesDir,
|
|
651
|
+
effectiveFps,
|
|
652
|
+
scale,
|
|
653
|
+
duration
|
|
654
|
+
);
|
|
608
655
|
ctx.emitProgress(100);
|
|
609
656
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
610
657
|
return frames;
|
|
611
658
|
}
|
|
612
|
-
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
613
|
-
const outputPattern = (0, import_node_path3.join)(outputDir,
|
|
659
|
+
async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
660
|
+
const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
|
|
614
661
|
await (0, import_execa.execa)(import_ffmpeg_static.default, [
|
|
615
662
|
"-i",
|
|
616
663
|
inputPath,
|
|
@@ -620,34 +667,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
|
620
667
|
"2",
|
|
621
668
|
outputPattern
|
|
622
669
|
]);
|
|
623
|
-
return buildFrameList(outputDir,
|
|
670
|
+
return buildFrameList(outputDir, duration);
|
|
624
671
|
}
|
|
625
|
-
async function
|
|
672
|
+
async function getVideoMetadata(inputPath) {
|
|
626
673
|
const { stdout } = await (0, import_execa.execa)(import_ffprobe.path, [
|
|
627
674
|
"-v",
|
|
628
675
|
"quiet",
|
|
629
676
|
"-print_format",
|
|
630
677
|
"json",
|
|
631
678
|
"-show_format",
|
|
679
|
+
"-show_streams",
|
|
632
680
|
inputPath
|
|
633
681
|
]);
|
|
634
|
-
|
|
635
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
682
|
+
return JSON.parse(stdout);
|
|
636
683
|
}
|
|
637
|
-
async function buildFrameList(framesDir,
|
|
684
|
+
async function buildFrameList(framesDir, duration) {
|
|
638
685
|
const files = await (0, import_promises2.readdir)(framesDir);
|
|
639
686
|
const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
|
|
640
687
|
if (jpgFiles.length === 0) {
|
|
641
688
|
return [];
|
|
642
689
|
}
|
|
643
|
-
let duration = 0;
|
|
644
|
-
try {
|
|
645
|
-
duration = await getVideoDuration(inputPath);
|
|
646
|
-
} catch {
|
|
647
|
-
logger.debug(
|
|
648
|
-
"Could not determine video duration; using frame index for timestamps"
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
690
|
return jpgFiles.map((file, index) => ({
|
|
652
691
|
id: index,
|
|
653
692
|
timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
|
|
@@ -673,13 +712,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
673
712
|
const outputPath = ctx.options.outputPath;
|
|
674
713
|
const quality = ctx.options.quality;
|
|
675
714
|
const outputFiles = [];
|
|
715
|
+
const framesMetadata = [];
|
|
716
|
+
const totalFramesCount = ctx.frames.length;
|
|
717
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
676
718
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
677
719
|
const frame = selectedFrames[i];
|
|
678
|
-
const
|
|
679
|
-
const destPath = (0, import_node_path4.join)(stagingDir,
|
|
720
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
721
|
+
const destPath = (0, import_node_path4.join)(stagingDir, fileName);
|
|
680
722
|
await (0, import_sharp2.default)(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
681
|
-
outputFiles.push((0, import_node_path4.join)(outputPath,
|
|
723
|
+
outputFiles.push((0, import_node_path4.join)(outputPath, fileName));
|
|
724
|
+
framesMetadata.push({
|
|
725
|
+
step: i + 1,
|
|
726
|
+
fileName,
|
|
727
|
+
frameId: frame.id + 1,
|
|
728
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
729
|
+
});
|
|
682
730
|
}
|
|
731
|
+
const metadata = {
|
|
732
|
+
video: {
|
|
733
|
+
originalDurationMs: Math.round(
|
|
734
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
735
|
+
),
|
|
736
|
+
fps: ctx.options.fps,
|
|
737
|
+
resolution: {
|
|
738
|
+
width: ctx.options.scale,
|
|
739
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
frames: framesMetadata,
|
|
743
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
744
|
+
...anim,
|
|
745
|
+
startFrameId: anim.startFrameId + 1,
|
|
746
|
+
endFrameId: anim.endFrameId + 1,
|
|
747
|
+
durationMs: Math.round(anim.durationMs)
|
|
748
|
+
}))
|
|
749
|
+
};
|
|
750
|
+
const metadataPath = (0, import_node_path4.join)(stagingDir, ".metadata.json");
|
|
751
|
+
await (0, import_promises3.writeFile)(metadataPath, JSON.stringify(metadata, null, 2));
|
|
752
|
+
outputFiles.push((0, import_node_path4.join)(outputPath, ".metadata.json"));
|
|
683
753
|
await ensureDir((0, import_node_path4.join)(outputPath, ".."));
|
|
684
754
|
await (0, import_promises3.rm)(outputPath, { recursive: true, force: true });
|
|
685
755
|
await (0, import_promises3.rename)(stagingDir, outputPath);
|
|
@@ -743,6 +813,8 @@ function resolveOptions(options) {
|
|
|
743
813
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
744
814
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
745
815
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
816
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
817
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
746
818
|
debug: options.debug ?? false
|
|
747
819
|
};
|
|
748
820
|
}
|
|
@@ -1013,7 +1085,9 @@ async function runPipeline(options) {
|
|
|
1013
1085
|
}
|
|
1014
1086
|
ctx.emitProgress(100);
|
|
1015
1087
|
ctx.status = "ANALYZING";
|
|
1016
|
-
|
|
1088
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1089
|
+
ctx.graph = edges;
|
|
1090
|
+
ctx.animations = animations;
|
|
1017
1091
|
ctx.status = "PRUNING";
|
|
1018
1092
|
const survivingIds = pruneByThresholdWithCap(
|
|
1019
1093
|
ctx.graph,
|
|
@@ -1046,6 +1120,15 @@ async function runPipeline(options) {
|
|
|
1046
1120
|
prunedFramesCount: prunedFrames.length,
|
|
1047
1121
|
outputFiles,
|
|
1048
1122
|
outputBuffers,
|
|
1123
|
+
animations: ctx.animations,
|
|
1124
|
+
video: {
|
|
1125
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1126
|
+
fps: ctx.options.fps,
|
|
1127
|
+
resolution: {
|
|
1128
|
+
width: ctx.options.scale,
|
|
1129
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1049
1132
|
executionTimeMs: Date.now() - startTime
|
|
1050
1133
|
};
|
|
1051
1134
|
} catch (error) {
|
package/dist/index.mjs
CHANGED
|
@@ -48,15 +48,8 @@ var DEFAULT_MAX_FRAMES = 300;
|
|
|
48
48
|
var NORMALIZATION_PERCENTILE = 0.9;
|
|
49
49
|
var WORKSPACE_PREFIX = `${APP_NAME}-`;
|
|
50
50
|
var TEMP_BASE_DIR = tmpdir();
|
|
51
|
-
var SUPPORTED_VIDEO_EXTENSIONS = [
|
|
52
|
-
".mp4",
|
|
53
|
-
".mov",
|
|
54
|
-
".avi",
|
|
55
|
-
".mkv",
|
|
56
|
-
".webm"
|
|
57
|
-
];
|
|
58
|
-
var SUPPORTED_GIF_EXTENSIONS = [".gif"];
|
|
59
51
|
var FRAME_OUTPUT_EXTENSION = ".jpg";
|
|
52
|
+
var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
|
|
60
53
|
var OPENCV_BATCH_SIZE = 10;
|
|
61
54
|
var DBSCAN_ALPHA = 0.03;
|
|
62
55
|
var DBSCAN_MIN_PTS = 4;
|
|
@@ -196,7 +189,13 @@ function computeIoU(a, b) {
|
|
|
196
189
|
return union === 0 ? 0 : intersection / union;
|
|
197
190
|
}
|
|
198
191
|
var IoUTracker = class {
|
|
192
|
+
constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
|
|
193
|
+
this.fps = fps;
|
|
194
|
+
this.iouThreshold = iouThreshold;
|
|
195
|
+
this.animationThreshold = animationThreshold;
|
|
196
|
+
}
|
|
199
197
|
regions = [];
|
|
198
|
+
extractedAnimations = [];
|
|
200
199
|
update(boxes, pairIndex) {
|
|
201
200
|
const animationIndices = /* @__PURE__ */ new Set();
|
|
202
201
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -212,7 +211,7 @@ var IoUTracker = class {
|
|
|
212
211
|
bestRegionIdx = ri;
|
|
213
212
|
}
|
|
214
213
|
}
|
|
215
|
-
if (bestIoU >
|
|
214
|
+
if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
|
|
216
215
|
const region = this.regions[bestRegionIdx];
|
|
217
216
|
const gap = pairIndex - region.lastSeen;
|
|
218
217
|
region.box = box;
|
|
@@ -220,13 +219,14 @@ var IoUTracker = class {
|
|
|
220
219
|
region.lastSeen = pairIndex;
|
|
221
220
|
region.weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
222
221
|
matched.add(bestRegionIdx);
|
|
223
|
-
if (region.consecutiveCount >=
|
|
222
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
224
223
|
animationIndices.add(bi);
|
|
225
224
|
}
|
|
226
225
|
} else {
|
|
227
226
|
this.regions.push({
|
|
228
227
|
box,
|
|
229
228
|
consecutiveCount: 1,
|
|
229
|
+
firstSeen: pairIndex,
|
|
230
230
|
lastSeen: pairIndex,
|
|
231
231
|
weight: 1
|
|
232
232
|
});
|
|
@@ -238,17 +238,45 @@ var IoUTracker = class {
|
|
|
238
238
|
this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
|
-
|
|
241
|
+
for (let i = 0; i < this.regions.length; i++) {
|
|
242
|
+
const region = this.regions[i];
|
|
243
|
+
if (region.weight <= 0.01 && !matched.has(i)) {
|
|
244
|
+
this.collectAnimation(region);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.regions = this.regions.filter(
|
|
248
|
+
(r, i) => r.weight > 0.01 || matched.has(i)
|
|
249
|
+
);
|
|
242
250
|
return animationIndices;
|
|
243
251
|
}
|
|
252
|
+
collectAnimation(region) {
|
|
253
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
254
|
+
const durationMs = region.consecutiveCount / this.fps * 1e3;
|
|
255
|
+
this.extractedAnimations.push({
|
|
256
|
+
type: "loading_spinner",
|
|
257
|
+
// 기본값으로 loading_spinner 사용
|
|
258
|
+
boundingBox: region.box,
|
|
259
|
+
startFrameId: region.firstSeen,
|
|
260
|
+
endFrameId: region.lastSeen,
|
|
261
|
+
durationMs
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
flushAndGetAnimations() {
|
|
266
|
+
for (const region of this.regions) {
|
|
267
|
+
this.collectAnimation(region);
|
|
268
|
+
}
|
|
269
|
+
this.regions = [];
|
|
270
|
+
return this.extractedAnimations;
|
|
271
|
+
}
|
|
244
272
|
getAnimationWeight(boxIndex, boxes) {
|
|
245
273
|
if (boxIndex >= boxes.length) return 0;
|
|
246
274
|
const box = boxes[boxIndex];
|
|
247
275
|
let maxWeight = 0;
|
|
248
276
|
for (const region of this.regions) {
|
|
249
|
-
if (region.consecutiveCount >=
|
|
277
|
+
if (region.consecutiveCount >= this.animationThreshold) {
|
|
250
278
|
const iou = computeIoU(box, region.box);
|
|
251
|
-
if (iou >
|
|
279
|
+
if (iou > this.iouThreshold) {
|
|
252
280
|
maxWeight = Math.max(maxWeight, region.weight);
|
|
253
281
|
}
|
|
254
282
|
}
|
|
@@ -470,13 +498,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
|
|
|
470
498
|
}
|
|
471
499
|
async function analyzeFrames(ctx) {
|
|
472
500
|
const { frames } = ctx;
|
|
473
|
-
if (frames.length < 2) return [];
|
|
501
|
+
if (frames.length < 2) return { edges: [], animations: [] };
|
|
474
502
|
logger.debug(
|
|
475
503
|
`Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
|
|
476
504
|
);
|
|
477
505
|
const cvLib = await ensureOpenCV();
|
|
478
506
|
const edges = [];
|
|
479
|
-
const tracker = new IoUTracker(
|
|
507
|
+
const tracker = new IoUTracker(
|
|
508
|
+
ctx.options.fps,
|
|
509
|
+
ctx.options.iouThreshold,
|
|
510
|
+
ctx.options.animationThreshold
|
|
511
|
+
);
|
|
480
512
|
const scale = ctx.options.scale;
|
|
481
513
|
for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
|
|
482
514
|
const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
|
|
@@ -489,8 +521,11 @@ async function analyzeFrames(ctx) {
|
|
|
489
521
|
);
|
|
490
522
|
ctx.emitProgress(progress);
|
|
491
523
|
}
|
|
492
|
-
|
|
493
|
-
|
|
524
|
+
const animations = tracker.flushAndGetAnimations();
|
|
525
|
+
logger.debug(
|
|
526
|
+
`Computed ${edges.length} score edges and ${animations.length} animations`
|
|
527
|
+
);
|
|
528
|
+
return { edges, animations };
|
|
494
529
|
}
|
|
495
530
|
|
|
496
531
|
// src/core/extractor.ts
|
|
@@ -530,9 +565,6 @@ function deriveOutputPath(inputPath) {
|
|
|
530
565
|
const name = basename(inputPath, extname(inputPath));
|
|
531
566
|
return resolve(dir, `${name}_scenes`);
|
|
532
567
|
}
|
|
533
|
-
function isSupportedFile(filePath, extensions) {
|
|
534
|
-
return extensions.includes(extname(filePath).toLowerCase());
|
|
535
|
-
}
|
|
536
568
|
|
|
537
569
|
// src/core/extractor.ts
|
|
538
570
|
async function extractFrames(ctx) {
|
|
@@ -545,32 +577,47 @@ async function extractFrames(ctx) {
|
|
|
545
577
|
if (!exists) {
|
|
546
578
|
throw new Error(`Input file not found: ${inputPath}`);
|
|
547
579
|
}
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (!
|
|
553
|
-
throw new Error(`
|
|
580
|
+
const metadata = await getVideoMetadata(inputPath).catch((err) => {
|
|
581
|
+
logger.debug(`ffprobe failed: ${err.message}`);
|
|
582
|
+
return null;
|
|
583
|
+
});
|
|
584
|
+
if (!metadata || !metadata.format) {
|
|
585
|
+
throw new Error(`Could not read file metadata: ${inputPath}`);
|
|
554
586
|
}
|
|
555
|
-
|
|
587
|
+
const formatName = metadata.format.format_name ?? "";
|
|
588
|
+
const duration = parseFloat(metadata.format.duration ?? "0");
|
|
589
|
+
const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
|
|
590
|
+
if (!hasVideoStream) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`No video stream found in file: ${inputPath} (detected format: ${formatName})`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
logger.debug(
|
|
596
|
+
`Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
|
|
597
|
+
);
|
|
556
598
|
await ensureDir(framesDir);
|
|
557
599
|
let effectiveFps = fps;
|
|
558
|
-
const duration = await getVideoDuration(inputPath).catch(() => 0);
|
|
559
600
|
if (duration > 0) {
|
|
560
601
|
const fpsCap = maxFrames / duration;
|
|
561
602
|
effectiveFps = Math.min(fps, fpsCap);
|
|
562
603
|
effectiveFps = Math.max(0.5, effectiveFps);
|
|
563
604
|
logger.debug(
|
|
564
|
-
`
|
|
605
|
+
`FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
|
|
565
606
|
);
|
|
566
607
|
}
|
|
567
|
-
const frames = await extractByFps(
|
|
608
|
+
const frames = await extractByFps(
|
|
609
|
+
inputPath,
|
|
610
|
+
framesDir,
|
|
611
|
+
effectiveFps,
|
|
612
|
+
scale,
|
|
613
|
+
duration
|
|
614
|
+
);
|
|
568
615
|
ctx.emitProgress(100);
|
|
569
616
|
logger.debug(`Extracted ${frames.length} frames`);
|
|
570
617
|
return frames;
|
|
571
618
|
}
|
|
572
|
-
async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
573
|
-
const outputPattern = join2(outputDir,
|
|
619
|
+
async function extractByFps(inputPath, outputDir, fps, scale, duration) {
|
|
620
|
+
const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
|
|
574
621
|
await execa(ffmpegPath, [
|
|
575
622
|
"-i",
|
|
576
623
|
inputPath,
|
|
@@ -580,34 +627,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
|
|
|
580
627
|
"2",
|
|
581
628
|
outputPattern
|
|
582
629
|
]);
|
|
583
|
-
return buildFrameList(outputDir,
|
|
630
|
+
return buildFrameList(outputDir, duration);
|
|
584
631
|
}
|
|
585
|
-
async function
|
|
632
|
+
async function getVideoMetadata(inputPath) {
|
|
586
633
|
const { stdout } = await execa(ffprobePath, [
|
|
587
634
|
"-v",
|
|
588
635
|
"quiet",
|
|
589
636
|
"-print_format",
|
|
590
637
|
"json",
|
|
591
638
|
"-show_format",
|
|
639
|
+
"-show_streams",
|
|
592
640
|
inputPath
|
|
593
641
|
]);
|
|
594
|
-
|
|
595
|
-
return parseFloat(metadata.format?.duration ?? "0");
|
|
642
|
+
return JSON.parse(stdout);
|
|
596
643
|
}
|
|
597
|
-
async function buildFrameList(framesDir,
|
|
644
|
+
async function buildFrameList(framesDir, duration) {
|
|
598
645
|
const files = await readdir(framesDir);
|
|
599
646
|
const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
|
|
600
647
|
if (jpgFiles.length === 0) {
|
|
601
648
|
return [];
|
|
602
649
|
}
|
|
603
|
-
let duration = 0;
|
|
604
|
-
try {
|
|
605
|
-
duration = await getVideoDuration(inputPath);
|
|
606
|
-
} catch {
|
|
607
|
-
logger.debug(
|
|
608
|
-
"Could not determine video duration; using frame index for timestamps"
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
650
|
return jpgFiles.map((file, index) => ({
|
|
612
651
|
id: index,
|
|
613
652
|
timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
|
|
@@ -633,13 +672,44 @@ async function finalizeOutput(ctx, selectedFrames) {
|
|
|
633
672
|
const outputPath = ctx.options.outputPath;
|
|
634
673
|
const quality = ctx.options.quality;
|
|
635
674
|
const outputFiles = [];
|
|
675
|
+
const framesMetadata = [];
|
|
676
|
+
const totalFramesCount = ctx.frames.length;
|
|
677
|
+
const padding = Math.max(4, String(totalFramesCount).length);
|
|
636
678
|
for (let i = 0; i < selectedFrames.length; i++) {
|
|
637
679
|
const frame = selectedFrames[i];
|
|
638
|
-
const
|
|
639
|
-
const destPath = join3(stagingDir,
|
|
680
|
+
const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
|
|
681
|
+
const destPath = join3(stagingDir, fileName);
|
|
640
682
|
await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
|
|
641
|
-
outputFiles.push(join3(outputPath,
|
|
683
|
+
outputFiles.push(join3(outputPath, fileName));
|
|
684
|
+
framesMetadata.push({
|
|
685
|
+
step: i + 1,
|
|
686
|
+
fileName,
|
|
687
|
+
frameId: frame.id + 1,
|
|
688
|
+
timestampMs: Math.round(frame.timestamp * 1e3)
|
|
689
|
+
});
|
|
642
690
|
}
|
|
691
|
+
const metadata = {
|
|
692
|
+
video: {
|
|
693
|
+
originalDurationMs: Math.round(
|
|
694
|
+
(ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
|
|
695
|
+
),
|
|
696
|
+
fps: ctx.options.fps,
|
|
697
|
+
resolution: {
|
|
698
|
+
width: ctx.options.scale,
|
|
699
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
frames: framesMetadata,
|
|
703
|
+
animations: (ctx.animations || []).map((anim) => ({
|
|
704
|
+
...anim,
|
|
705
|
+
startFrameId: anim.startFrameId + 1,
|
|
706
|
+
endFrameId: anim.endFrameId + 1,
|
|
707
|
+
durationMs: Math.round(anim.durationMs)
|
|
708
|
+
}))
|
|
709
|
+
};
|
|
710
|
+
const metadataPath = join3(stagingDir, ".metadata.json");
|
|
711
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
712
|
+
outputFiles.push(join3(outputPath, ".metadata.json"));
|
|
643
713
|
await ensureDir(join3(outputPath, ".."));
|
|
644
714
|
await rm(outputPath, { recursive: true, force: true });
|
|
645
715
|
await rename(stagingDir, outputPath);
|
|
@@ -703,6 +773,8 @@ function resolveOptions(options) {
|
|
|
703
773
|
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
704
774
|
scale: options.scale ?? DEFAULT_SCALE,
|
|
705
775
|
quality: options.quality ?? DEFAULT_QUALITY,
|
|
776
|
+
iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
|
|
777
|
+
animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
|
|
706
778
|
debug: options.debug ?? false
|
|
707
779
|
};
|
|
708
780
|
}
|
|
@@ -973,7 +1045,9 @@ async function runPipeline(options) {
|
|
|
973
1045
|
}
|
|
974
1046
|
ctx.emitProgress(100);
|
|
975
1047
|
ctx.status = "ANALYZING";
|
|
976
|
-
|
|
1048
|
+
const { edges, animations } = await analyzeFrames(ctx);
|
|
1049
|
+
ctx.graph = edges;
|
|
1050
|
+
ctx.animations = animations;
|
|
977
1051
|
ctx.status = "PRUNING";
|
|
978
1052
|
const survivingIds = pruneByThresholdWithCap(
|
|
979
1053
|
ctx.graph,
|
|
@@ -1006,6 +1080,15 @@ async function runPipeline(options) {
|
|
|
1006
1080
|
prunedFramesCount: prunedFrames.length,
|
|
1007
1081
|
outputFiles,
|
|
1008
1082
|
outputBuffers,
|
|
1083
|
+
animations: ctx.animations,
|
|
1084
|
+
video: {
|
|
1085
|
+
originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
|
|
1086
|
+
fps: ctx.options.fps,
|
|
1087
|
+
resolution: {
|
|
1088
|
+
width: ctx.options.scale,
|
|
1089
|
+
height: Math.round(ctx.options.scale * 9 / 16)
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
1009
1092
|
executionTimeMs: Date.now() - startTime
|
|
1010
1093
|
};
|
|
1011
1094
|
} catch (error) {
|