@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.
@@ -51,15 +51,8 @@ var DEFAULT_MAX_FRAMES = 300;
51
51
  var NORMALIZATION_PERCENTILE = 0.9;
52
52
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
53
53
  var TEMP_BASE_DIR = tmpdir();
54
- var SUPPORTED_VIDEO_EXTENSIONS = [
55
- ".mp4",
56
- ".mov",
57
- ".avi",
58
- ".mkv",
59
- ".webm"
60
- ];
61
- var SUPPORTED_GIF_EXTENSIONS = [".gif"];
62
54
  var FRAME_OUTPUT_EXTENSION = ".jpg";
55
+ var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
63
56
  var OPENCV_BATCH_SIZE = 10;
64
57
  var DBSCAN_ALPHA = 0.03;
65
58
  var DBSCAN_MIN_PTS = 4;
@@ -199,7 +192,13 @@ function computeIoU(a, b) {
199
192
  return union === 0 ? 0 : intersection / union;
200
193
  }
201
194
  var IoUTracker = class {
195
+ constructor(fps = DEFAULT_FPS, iouThreshold = IOU_THRESHOLD, animationThreshold = ANIMATION_FRAME_THRESHOLD) {
196
+ this.fps = fps;
197
+ this.iouThreshold = iouThreshold;
198
+ this.animationThreshold = animationThreshold;
199
+ }
202
200
  regions = [];
201
+ extractedAnimations = [];
203
202
  update(boxes, pairIndex) {
204
203
  const animationIndices = /* @__PURE__ */ new Set();
205
204
  const matched = /* @__PURE__ */ new Set();
@@ -215,7 +214,7 @@ var IoUTracker = class {
215
214
  bestRegionIdx = ri;
216
215
  }
217
216
  }
218
- if (bestIoU > IOU_THRESHOLD && bestRegionIdx !== -1) {
217
+ if (bestIoU > this.iouThreshold && bestRegionIdx !== -1) {
219
218
  const region = this.regions[bestRegionIdx];
220
219
  const gap = pairIndex - region.lastSeen;
221
220
  region.box = box;
@@ -223,13 +222,14 @@ var IoUTracker = class {
223
222
  region.lastSeen = pairIndex;
224
223
  region.weight *= Math.pow(DECAY_LAMBDA, gap);
225
224
  matched.add(bestRegionIdx);
226
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
225
+ if (region.consecutiveCount >= this.animationThreshold) {
227
226
  animationIndices.add(bi);
228
227
  }
229
228
  } else {
230
229
  this.regions.push({
231
230
  box,
232
231
  consecutiveCount: 1,
232
+ firstSeen: pairIndex,
233
233
  lastSeen: pairIndex,
234
234
  weight: 1
235
235
  });
@@ -241,17 +241,45 @@ var IoUTracker = class {
241
241
  this.regions[ri].weight *= Math.pow(DECAY_LAMBDA, gap);
242
242
  }
243
243
  }
244
- this.regions = this.regions.filter((r) => r.weight > 0.01);
244
+ for (let i = 0; i < this.regions.length; i++) {
245
+ const region = this.regions[i];
246
+ if (region.weight <= 0.01 && !matched.has(i)) {
247
+ this.collectAnimation(region);
248
+ }
249
+ }
250
+ this.regions = this.regions.filter(
251
+ (r, i) => r.weight > 0.01 || matched.has(i)
252
+ );
245
253
  return animationIndices;
246
254
  }
255
+ collectAnimation(region) {
256
+ if (region.consecutiveCount >= this.animationThreshold) {
257
+ const durationMs = region.consecutiveCount / this.fps * 1e3;
258
+ this.extractedAnimations.push({
259
+ type: "loading_spinner",
260
+ // 기본값으로 loading_spinner 사용
261
+ boundingBox: region.box,
262
+ startFrameId: region.firstSeen,
263
+ endFrameId: region.lastSeen,
264
+ durationMs
265
+ });
266
+ }
267
+ }
268
+ flushAndGetAnimations() {
269
+ for (const region of this.regions) {
270
+ this.collectAnimation(region);
271
+ }
272
+ this.regions = [];
273
+ return this.extractedAnimations;
274
+ }
247
275
  getAnimationWeight(boxIndex, boxes) {
248
276
  if (boxIndex >= boxes.length) return 0;
249
277
  const box = boxes[boxIndex];
250
278
  let maxWeight = 0;
251
279
  for (const region of this.regions) {
252
- if (region.consecutiveCount >= ANIMATION_FRAME_THRESHOLD) {
280
+ if (region.consecutiveCount >= this.animationThreshold) {
253
281
  const iou = computeIoU(box, region.box);
254
- if (iou > IOU_THRESHOLD) {
282
+ if (iou > this.iouThreshold) {
255
283
  maxWeight = Math.max(maxWeight, region.weight);
256
284
  }
257
285
  }
@@ -473,13 +501,17 @@ async function analyzeBatch(cvLib, frames, scale, tracker, pairOffset) {
473
501
  }
474
502
  async function analyzeFrames(ctx) {
475
503
  const { frames } = ctx;
476
- if (frames.length < 2) return [];
504
+ if (frames.length < 2) return { edges: [], animations: [] };
477
505
  logger.debug(
478
506
  `Analyzing ${frames.length} frames in batches of ${OPENCV_BATCH_SIZE}`
479
507
  );
480
508
  const cvLib = await ensureOpenCV();
481
509
  const edges = [];
482
- const tracker = new IoUTracker();
510
+ const tracker = new IoUTracker(
511
+ ctx.options.fps,
512
+ ctx.options.iouThreshold,
513
+ ctx.options.animationThreshold
514
+ );
483
515
  const scale = ctx.options.scale;
484
516
  for (let i = 0; i < frames.length - 1; i += OPENCV_BATCH_SIZE) {
485
517
  const batchEnd = Math.min(i + OPENCV_BATCH_SIZE + 1, frames.length);
@@ -492,8 +524,11 @@ async function analyzeFrames(ctx) {
492
524
  );
493
525
  ctx.emitProgress(progress);
494
526
  }
495
- logger.debug(`Computed ${edges.length} score edges`);
496
- return edges;
527
+ const animations = tracker.flushAndGetAnimations();
528
+ logger.debug(
529
+ `Computed ${edges.length} score edges and ${animations.length} animations`
530
+ );
531
+ return { edges, animations };
497
532
  }
498
533
 
499
534
  // src/core/extractor.ts
@@ -533,9 +568,6 @@ function deriveOutputPath(inputPath) {
533
568
  const name = basename(inputPath, extname(inputPath));
534
569
  return resolve(dir, `${name}_scenes`);
535
570
  }
536
- function isSupportedFile(filePath, extensions) {
537
- return extensions.includes(extname(filePath).toLowerCase());
538
- }
539
571
 
540
572
  // src/core/extractor.ts
541
573
  async function extractFrames(ctx) {
@@ -548,32 +580,47 @@ async function extractFrames(ctx) {
548
580
  if (!exists) {
549
581
  throw new Error(`Input file not found: ${inputPath}`);
550
582
  }
551
- const allExtensions = [
552
- ...SUPPORTED_VIDEO_EXTENSIONS,
553
- ...SUPPORTED_GIF_EXTENSIONS
554
- ];
555
- if (!isSupportedFile(inputPath, allExtensions)) {
556
- throw new Error(`Unsupported file format: ${inputPath}`);
583
+ const metadata = await getVideoMetadata(inputPath).catch((err) => {
584
+ logger.debug(`ffprobe failed: ${err.message}`);
585
+ return null;
586
+ });
587
+ if (!metadata || !metadata.format) {
588
+ throw new Error(`Could not read file metadata: ${inputPath}`);
589
+ }
590
+ const formatName = metadata.format.format_name ?? "";
591
+ const duration = parseFloat(metadata.format.duration ?? "0");
592
+ const hasVideoStream = metadata.streams?.some((s) => s.codec_type === "video") ?? false;
593
+ if (!hasVideoStream) {
594
+ throw new Error(
595
+ `No video stream found in file: ${inputPath} (detected format: ${formatName})`
596
+ );
557
597
  }
558
- logger.debug(`Extracting frames from: ${inputPath}`);
598
+ logger.debug(
599
+ `Detected format: ${formatName} (Duration: ${duration.toFixed(1)}s), path: ${inputPath}`
600
+ );
559
601
  await ensureDir(framesDir);
560
602
  let effectiveFps = fps;
561
- const duration = await getVideoDuration(inputPath).catch(() => 0);
562
603
  if (duration > 0) {
563
604
  const fpsCap = maxFrames / duration;
564
605
  effectiveFps = Math.min(fps, fpsCap);
565
606
  effectiveFps = Math.max(0.5, effectiveFps);
566
607
  logger.debug(
567
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
568
609
  );
569
610
  }
570
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
611
+ const frames = await extractByFps(
612
+ inputPath,
613
+ framesDir,
614
+ effectiveFps,
615
+ scale,
616
+ duration
617
+ );
571
618
  ctx.emitProgress(100);
572
619
  logger.debug(`Extracted ${frames.length} frames`);
573
620
  return frames;
574
621
  }
575
- async function extractByFps(inputPath, outputDir, fps, scale) {
576
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
622
+ async function extractByFps(inputPath, outputDir, fps, scale, duration) {
623
+ const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
577
624
  await execa(ffmpegPath, [
578
625
  "-i",
579
626
  inputPath,
@@ -583,34 +630,26 @@ async function extractByFps(inputPath, outputDir, fps, scale) {
583
630
  "2",
584
631
  outputPattern
585
632
  ]);
586
- return buildFrameList(outputDir, inputPath);
633
+ return buildFrameList(outputDir, duration);
587
634
  }
588
- async function getVideoDuration(inputPath) {
635
+ async function getVideoMetadata(inputPath) {
589
636
  const { stdout } = await execa(ffprobePath, [
590
637
  "-v",
591
638
  "quiet",
592
639
  "-print_format",
593
640
  "json",
594
641
  "-show_format",
642
+ "-show_streams",
595
643
  inputPath
596
644
  ]);
597
- const metadata = JSON.parse(stdout);
598
- return parseFloat(metadata.format?.duration ?? "0");
645
+ return JSON.parse(stdout);
599
646
  }
600
- async function buildFrameList(framesDir, inputPath) {
647
+ async function buildFrameList(framesDir, duration) {
601
648
  const files = await readdir(framesDir);
602
649
  const jpgFiles = files.filter((f) => f.endsWith(".jpg")).sort();
603
650
  if (jpgFiles.length === 0) {
604
651
  return [];
605
652
  }
606
- let duration = 0;
607
- try {
608
- duration = await getVideoDuration(inputPath);
609
- } catch {
610
- logger.debug(
611
- "Could not determine video duration; using frame index for timestamps"
612
- );
613
- }
614
653
  return jpgFiles.map((file, index) => ({
615
654
  id: index,
616
655
  timestamp: duration > 0 && jpgFiles.length > 1 ? duration * index / (jpgFiles.length - 1) : index,
@@ -636,13 +675,44 @@ async function finalizeOutput(ctx, selectedFrames) {
636
675
  const outputPath = ctx.options.outputPath;
637
676
  const quality = ctx.options.quality;
638
677
  const outputFiles = [];
678
+ const framesMetadata = [];
679
+ const totalFramesCount = ctx.frames.length;
680
+ const padding = Math.max(4, String(totalFramesCount).length);
639
681
  for (let i = 0; i < selectedFrames.length; i++) {
640
682
  const frame = selectedFrames[i];
641
- const destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
642
- const destPath = join3(stagingDir, destName);
683
+ const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
684
+ const destPath = join3(stagingDir, fileName);
643
685
  await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
644
- outputFiles.push(join3(outputPath, destName));
686
+ outputFiles.push(join3(outputPath, fileName));
687
+ framesMetadata.push({
688
+ step: i + 1,
689
+ fileName,
690
+ frameId: frame.id + 1,
691
+ timestampMs: Math.round(frame.timestamp * 1e3)
692
+ });
645
693
  }
694
+ const metadata = {
695
+ video: {
696
+ originalDurationMs: Math.round(
697
+ (ctx.frames.length > 0 ? ctx.frames[ctx.frames.length - 1].timestamp : 0) * 1e3
698
+ ),
699
+ fps: ctx.options.fps,
700
+ resolution: {
701
+ width: ctx.options.scale,
702
+ height: Math.round(ctx.options.scale * 9 / 16)
703
+ }
704
+ },
705
+ frames: framesMetadata,
706
+ animations: (ctx.animations || []).map((anim) => ({
707
+ ...anim,
708
+ startFrameId: anim.startFrameId + 1,
709
+ endFrameId: anim.endFrameId + 1,
710
+ durationMs: Math.round(anim.durationMs)
711
+ }))
712
+ };
713
+ const metadataPath = join3(stagingDir, ".metadata.json");
714
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
715
+ outputFiles.push(join3(outputPath, ".metadata.json"));
646
716
  await ensureDir(join3(outputPath, ".."));
647
717
  await rm(outputPath, { recursive: true, force: true });
648
718
  await rename(stagingDir, outputPath);
@@ -706,6 +776,8 @@ function resolveOptions(options2) {
706
776
  maxFrames: options2.maxFrames ?? DEFAULT_MAX_FRAMES,
707
777
  scale: options2.scale ?? DEFAULT_SCALE,
708
778
  quality: options2.quality ?? DEFAULT_QUALITY,
779
+ iouThreshold: options2.iouThreshold ?? IOU_THRESHOLD,
780
+ animationThreshold: options2.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
709
781
  debug: options2.debug ?? false
710
782
  };
711
783
  }
@@ -976,7 +1048,9 @@ async function runPipeline(options2) {
976
1048
  }
977
1049
  ctx.emitProgress(100);
978
1050
  ctx.status = "ANALYZING";
979
- ctx.graph = await analyzeFrames(ctx);
1051
+ const { edges, animations } = await analyzeFrames(ctx);
1052
+ ctx.graph = edges;
1053
+ ctx.animations = animations;
980
1054
  ctx.status = "PRUNING";
981
1055
  const survivingIds = pruneByThresholdWithCap(
982
1056
  ctx.graph,
@@ -1009,6 +1083,15 @@ async function runPipeline(options2) {
1009
1083
  prunedFramesCount: prunedFrames.length,
1010
1084
  outputFiles,
1011
1085
  outputBuffers,
1086
+ animations: ctx.animations,
1087
+ video: {
1088
+ originalDurationMs: ctx.frames.length / ctx.options.fps * 1e3,
1089
+ fps: ctx.options.fps,
1090
+ resolution: {
1091
+ width: ctx.options.scale,
1092
+ height: Math.round(ctx.options.scale * 9 / 16)
1093
+ }
1094
+ },
1012
1095
  executionTimeMs: Date.now() - startTime
1013
1096
  };
1014
1097
  } catch (error) {
@@ -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
+ }
@@ -16,4 +16,3 @@ export declare function resolveAbsolute(p: string): string;
16
16
  * e.g., /path/to/video.mp4 -> /path/to/video_scenes
17
17
  */
18
18
  export declare function deriveOutputPath(inputPath: string): string;
19
- export declare function isSupportedFile(filePath: string, extensions: string[]): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumy-pack/scene-sieve",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",