@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.
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ export interface SieveViewProps {
3
+ input: string;
4
+ count?: number;
5
+ threshold?: number;
6
+ output?: string;
7
+ fps: number;
8
+ maxFrames: number;
9
+ scale: number;
10
+ quality: number;
11
+ iouThreshold?: number;
12
+ animationThreshold?: number;
13
+ debug: boolean;
14
+ }
15
+ export declare const SieveView: React.FC<SieveViewProps>;
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ export type PhaseStatus = 'pending' | 'running' | 'done' | 'failed';
3
+ export interface PhaseState {
4
+ label: string;
5
+ status: PhaseStatus;
6
+ hasProgress: boolean;
7
+ percent: number;
8
+ durationMs?: number;
9
+ }
10
+ interface PhaseStepProps {
11
+ phase: PhaseState;
12
+ }
13
+ export declare const PhaseStep: React.FC<PhaseStepProps>;
14
+ export {};
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ interface ProgressBarProps {
3
+ percent: number;
4
+ width?: number;
5
+ }
6
+ export declare const ProgressBar: React.FC<ProgressBarProps>;
7
+ export {};
@@ -4,6 +4,7 @@ export declare const DEFAULT_THRESHOLD = 0.5;
4
4
  export declare const DEFAULT_FPS = 5;
5
5
  export declare const DEFAULT_SCALE = 720;
6
6
  export declare const DEFAULT_QUALITY = 80;
7
+ export declare const DEFAULT_MAX_FRAMES = 300;
7
8
  export declare const NORMALIZATION_PERCENTILE = 0.9;
8
9
  export declare const WORKSPACE_PREFIX = "scene-sieve-";
9
10
  export declare const TEMP_BASE_DIR: string;
@@ -1,4 +1,4 @@
1
- import type { BoundingBox, ProcessContext, ScoreEdge } from '../types/index.js';
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<ScoreEdge[]>;
61
+ export declare function analyzeFrames(ctx: ProcessContext): Promise<AnalysisResult>;
55
62
  export {};
@@ -1,7 +1,7 @@
1
1
  import type { FrameNode, ProcessContext } from '../types/index.js';
2
2
  /**
3
3
  * Extract frames from video/GIF using FFmpeg.
4
- * Attempts I-Frame extraction first; falls back to fixed FPS if insufficient.
5
- * GIF inputs always use FPS fallback.
4
+ * Always uses FPS-based extraction. For long videos, FPS is automatically
5
+ * reduced to stay within maxFrames budget.
6
6
  */
7
7
  export declare function extractFrames(ctx: ProcessContext): Promise<FrameNode[]>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { ProgressPhase, SieveInput, SieveOptionsBase, SieveResult } from '../types/index.js';
2
+ export type SieveWorkerOptions = Omit<SieveOptionsBase, 'onProgress'> & SieveInput;
3
+ /**
4
+ * Run the pipeline, choosing the best execution strategy:
5
+ *
6
+ * - Production (bundled .mjs): Worker thread — spinner never freezes
7
+ * - Dev mode (tsx .ts): Main thread — simpler, spinner may stutter during CPU work
8
+ */
9
+ export declare function runPipelineInWorker(options: SieveWorkerOptions, onProgress: (phase: ProgressPhase, percent: number) => void): Promise<SieveResult>;
@@ -2,6 +2,11 @@ import type { FrameNode, ProcessContext } from '../types/index.js';
2
2
  export declare function createWorkspace(sessionId: string): Promise<string>;
3
3
  export declare function finalizeOutput(ctx: ProcessContext, selectedFrames: FrameNode[]): Promise<string[]>;
4
4
  export declare function cleanupWorkspace(workspacePath: string): Promise<void>;
5
+ /**
6
+ * Remove stale workspace directories left by previous interrupted runs.
7
+ * Only deletes directories older than 1 hour to avoid removing active workspaces.
8
+ */
9
+ export declare function cleanupStaleWorkspaces(): Promise<void>;
5
10
  /**
6
11
  * Write a video buffer to a temp file in the workspace and return the path.
7
12
  * Used by 'buffer' input mode.
package/dist/index.cjs CHANGED
@@ -84,6 +84,7 @@ var DEFAULT_THRESHOLD = 0.5;
84
84
  var DEFAULT_FPS = 5;
85
85
  var DEFAULT_SCALE = 720;
86
86
  var DEFAULT_QUALITY = 80;
87
+ var DEFAULT_MAX_FRAMES = 300;
87
88
  var NORMALIZATION_PERCENTILE = 0.9;
88
89
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
89
90
  var TEMP_BASE_DIR = (0, import_node_os.tmpdir)();
@@ -96,8 +97,8 @@ var SUPPORTED_VIDEO_EXTENSIONS = [
96
97
  ];
97
98
  var SUPPORTED_GIF_EXTENSIONS = [".gif"];
98
99
  var FRAME_OUTPUT_EXTENSION = ".jpg";
100
+ var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
99
101
  var OPENCV_BATCH_SIZE = 10;
100
- var MIN_IFRAME_COUNT = 3;
101
102
  var DBSCAN_ALPHA = 0.03;
102
103
  var DBSCAN_MIN_PTS = 4;
103
104
  var IOU_THRESHOLD = 0.9;
@@ -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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
325
+ if (region.consecutiveCount >= this.animationThreshold) {
290
326
  const iou = computeIoU(box, region.box);
291
- if (iou > IOU_THRESHOLD) {
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
- logger.debug(`Computed ${edges.length} score edges`);
533
- return edges;
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
@@ -577,7 +620,7 @@ function isSupportedFile(filePath, extensions) {
577
620
  // src/core/extractor.ts
578
621
  async function extractFrames(ctx) {
579
622
  const framesDir = (0, import_node_path3.join)(ctx.workspacePath, "frames");
580
- const { inputPath, fps, scale } = ctx.options;
623
+ const { inputPath, fps, maxFrames, scale } = ctx.options;
581
624
  if (!inputPath) {
582
625
  throw new Error("inputPath is required for frame extraction");
583
626
  }
@@ -594,41 +637,23 @@ async function extractFrames(ctx) {
594
637
  }
595
638
  logger.debug(`Extracting frames from: ${inputPath}`);
596
639
  await ensureDir(framesDir);
597
- const isGif = isSupportedFile(inputPath, SUPPORTED_GIF_EXTENSIONS);
598
- let frames;
599
- if (isGif) {
600
- logger.debug("GIF detected \u2014 using FPS extraction");
601
- frames = await extractByFps(inputPath, framesDir, fps, scale);
602
- } else {
603
- frames = await extractIFrames(inputPath, framesDir, scale);
604
- if (frames.length < MIN_IFRAME_COUNT) {
605
- logger.debug(
606
- `Insufficient I-frames (${frames.length}), falling back to FPS mode`
607
- );
608
- frames = await extractByFps(inputPath, framesDir, fps, scale);
609
- }
640
+ let effectiveFps = fps;
641
+ const duration = await getVideoDuration(inputPath).catch(() => 0);
642
+ if (duration > 0) {
643
+ const fpsCap = maxFrames / duration;
644
+ effectiveFps = Math.min(fps, fpsCap);
645
+ effectiveFps = Math.max(0.5, effectiveFps);
646
+ logger.debug(
647
+ `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
648
+ );
610
649
  }
650
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
611
651
  ctx.emitProgress(100);
612
652
  logger.debug(`Extracted ${frames.length} frames`);
613
653
  return frames;
614
654
  }
615
- async function extractIFrames(inputPath, outputDir, scale) {
616
- const outputPattern = (0, import_node_path3.join)(outputDir, "frame_%06d.jpg");
617
- await (0, import_execa.execa)(import_ffmpeg_static.default, [
618
- "-i",
619
- inputPath,
620
- "-vf",
621
- `select='eq(pict_type,I)',scale=-1:${scale}`,
622
- "-vsync",
623
- "vfr",
624
- "-q:v",
625
- "2",
626
- outputPattern
627
- ]);
628
- return buildFrameList(outputDir, inputPath);
629
- }
630
655
  async function extractByFps(inputPath, outputDir, fps, scale) {
631
- const outputPattern = (0, import_node_path3.join)(outputDir, "frame_%06d.jpg");
656
+ const outputPattern = (0, import_node_path3.join)(outputDir, FRAME_FILENAME_PATTERN);
632
657
  await (0, import_execa.execa)(import_ffmpeg_static.default, [
633
658
  "-i",
634
659
  inputPath,
@@ -691,13 +716,44 @@ async function finalizeOutput(ctx, selectedFrames) {
691
716
  const outputPath = ctx.options.outputPath;
692
717
  const quality = ctx.options.quality;
693
718
  const outputFiles = [];
719
+ const framesMetadata = [];
720
+ const totalFramesCount = ctx.frames.length;
721
+ const padding = Math.max(4, String(totalFramesCount).length);
694
722
  for (let i = 0; i < selectedFrames.length; i++) {
695
723
  const frame = selectedFrames[i];
696
- const destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
697
- const destPath = (0, import_node_path4.join)(stagingDir, destName);
724
+ const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
725
+ const destPath = (0, import_node_path4.join)(stagingDir, fileName);
698
726
  await (0, import_sharp2.default)(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
699
- outputFiles.push((0, import_node_path4.join)(outputPath, destName));
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
+ });
700
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"));
701
757
  await ensureDir((0, import_node_path4.join)(outputPath, ".."));
702
758
  await (0, import_promises3.rm)(outputPath, { recursive: true, force: true });
703
759
  await (0, import_promises3.rename)(stagingDir, outputPath);
@@ -710,6 +766,7 @@ async function cleanupWorkspace(workspacePath) {
710
766
  } catch {
711
767
  }
712
768
  }
769
+ var STALE_THRESHOLD_MS = 60 * 60 * 1e3;
713
770
  async function writeInputBuffer(buffer, workspacePath) {
714
771
  const inputDir = (0, import_node_path4.join)(workspacePath, "input");
715
772
  await ensureDir(inputDir);
@@ -757,8 +814,11 @@ function resolveOptions(options) {
757
814
  pruneMode,
758
815
  outputPath,
759
816
  fps: options.fps ?? DEFAULT_FPS,
817
+ maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
760
818
  scale: options.scale ?? DEFAULT_SCALE,
761
819
  quality: options.quality ?? DEFAULT_QUALITY,
820
+ iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
821
+ animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
762
822
  debug: options.debug ?? false
763
823
  };
764
824
  }
@@ -1029,7 +1089,9 @@ async function runPipeline(options) {
1029
1089
  }
1030
1090
  ctx.emitProgress(100);
1031
1091
  ctx.status = "ANALYZING";
1032
- ctx.graph = await analyzeFrames(ctx);
1092
+ const { edges, animations } = await analyzeFrames(ctx);
1093
+ ctx.graph = edges;
1094
+ ctx.animations = animations;
1033
1095
  ctx.status = "PRUNING";
1034
1096
  const survivingIds = pruneByThresholdWithCap(
1035
1097
  ctx.graph,
@@ -1062,6 +1124,15 @@ async function runPipeline(options) {
1062
1124
  prunedFramesCount: prunedFrames.length,
1063
1125
  outputFiles,
1064
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
+ },
1065
1136
  executionTimeMs: Date.now() - startTime
1066
1137
  };
1067
1138
  } catch (error) {