@lumy-pack/scene-sieve 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -44,6 +44,7 @@ var DEFAULT_THRESHOLD = 0.5;
44
44
  var DEFAULT_FPS = 5;
45
45
  var DEFAULT_SCALE = 720;
46
46
  var DEFAULT_QUALITY = 80;
47
+ var DEFAULT_MAX_FRAMES = 300;
47
48
  var NORMALIZATION_PERCENTILE = 0.9;
48
49
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
49
50
  var TEMP_BASE_DIR = tmpdir();
@@ -56,8 +57,8 @@ var SUPPORTED_VIDEO_EXTENSIONS = [
56
57
  ];
57
58
  var SUPPORTED_GIF_EXTENSIONS = [".gif"];
58
59
  var FRAME_OUTPUT_EXTENSION = ".jpg";
60
+ var FRAME_FILENAME_PATTERN = "frame_%06d.jpg";
59
61
  var OPENCV_BATCH_SIZE = 10;
60
- var MIN_IFRAME_COUNT = 3;
61
62
  var DBSCAN_ALPHA = 0.03;
62
63
  var DBSCAN_MIN_PTS = 4;
63
64
  var IOU_THRESHOLD = 0.9;
@@ -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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
285
+ if (region.consecutiveCount >= this.animationThreshold) {
250
286
  const iou = computeIoU(box, region.box);
251
- if (iou > IOU_THRESHOLD) {
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
- logger.debug(`Computed ${edges.length} score edges`);
493
- return edges;
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
@@ -537,7 +580,7 @@ function isSupportedFile(filePath, extensions) {
537
580
  // src/core/extractor.ts
538
581
  async function extractFrames(ctx) {
539
582
  const framesDir = join2(ctx.workspacePath, "frames");
540
- const { inputPath, fps, scale } = ctx.options;
583
+ const { inputPath, fps, maxFrames, scale } = ctx.options;
541
584
  if (!inputPath) {
542
585
  throw new Error("inputPath is required for frame extraction");
543
586
  }
@@ -554,41 +597,23 @@ async function extractFrames(ctx) {
554
597
  }
555
598
  logger.debug(`Extracting frames from: ${inputPath}`);
556
599
  await ensureDir(framesDir);
557
- const isGif = isSupportedFile(inputPath, SUPPORTED_GIF_EXTENSIONS);
558
- let frames;
559
- if (isGif) {
560
- logger.debug("GIF detected \u2014 using FPS extraction");
561
- frames = await extractByFps(inputPath, framesDir, fps, scale);
562
- } else {
563
- frames = await extractIFrames(inputPath, framesDir, scale);
564
- if (frames.length < MIN_IFRAME_COUNT) {
565
- logger.debug(
566
- `Insufficient I-frames (${frames.length}), falling back to FPS mode`
567
- );
568
- frames = await extractByFps(inputPath, framesDir, fps, scale);
569
- }
600
+ let effectiveFps = fps;
601
+ const duration = await getVideoDuration(inputPath).catch(() => 0);
602
+ if (duration > 0) {
603
+ const fpsCap = maxFrames / duration;
604
+ effectiveFps = Math.min(fps, fpsCap);
605
+ effectiveFps = Math.max(0.5, effectiveFps);
606
+ logger.debug(
607
+ `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
608
+ );
570
609
  }
610
+ const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
571
611
  ctx.emitProgress(100);
572
612
  logger.debug(`Extracted ${frames.length} frames`);
573
613
  return frames;
574
614
  }
575
- async function extractIFrames(inputPath, outputDir, scale) {
576
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
577
- await execa(ffmpegPath, [
578
- "-i",
579
- inputPath,
580
- "-vf",
581
- `select='eq(pict_type,I)',scale=-1:${scale}`,
582
- "-vsync",
583
- "vfr",
584
- "-q:v",
585
- "2",
586
- outputPattern
587
- ]);
588
- return buildFrameList(outputDir, inputPath);
589
- }
590
615
  async function extractByFps(inputPath, outputDir, fps, scale) {
591
- const outputPattern = join2(outputDir, "frame_%06d.jpg");
616
+ const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
592
617
  await execa(ffmpegPath, [
593
618
  "-i",
594
619
  inputPath,
@@ -637,7 +662,7 @@ async function buildFrameList(framesDir, inputPath) {
637
662
  import { join as join4 } from "path";
638
663
 
639
664
  // src/core/workspace.ts
640
- import { rename, rm, writeFile } from "fs/promises";
665
+ import { readdir as readdir2, rename, rm, stat as stat2, writeFile } from "fs/promises";
641
666
  import { join as join3 } from "path";
642
667
  import sharp2 from "sharp";
643
668
  async function createWorkspace(sessionId) {
@@ -651,13 +676,44 @@ async function finalizeOutput(ctx, selectedFrames) {
651
676
  const outputPath = ctx.options.outputPath;
652
677
  const quality = ctx.options.quality;
653
678
  const outputFiles = [];
679
+ const framesMetadata = [];
680
+ const totalFramesCount = ctx.frames.length;
681
+ const padding = Math.max(4, String(totalFramesCount).length);
654
682
  for (let i = 0; i < selectedFrames.length; i++) {
655
683
  const frame = selectedFrames[i];
656
- const destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
657
- const destPath = join3(stagingDir, destName);
684
+ const fileName = `frame_${String(frame.id + 1).padStart(padding, "0")}.jpg`;
685
+ const destPath = join3(stagingDir, fileName);
658
686
  await sharp2(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
659
- outputFiles.push(join3(outputPath, destName));
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
+ });
660
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"));
661
717
  await ensureDir(join3(outputPath, ".."));
662
718
  await rm(outputPath, { recursive: true, force: true });
663
719
  await rename(stagingDir, outputPath);
@@ -670,6 +726,7 @@ async function cleanupWorkspace(workspacePath) {
670
726
  } catch {
671
727
  }
672
728
  }
729
+ var STALE_THRESHOLD_MS = 60 * 60 * 1e3;
673
730
  async function writeInputBuffer(buffer, workspacePath) {
674
731
  const inputDir = join3(workspacePath, "input");
675
732
  await ensureDir(inputDir);
@@ -717,8 +774,11 @@ function resolveOptions(options) {
717
774
  pruneMode,
718
775
  outputPath,
719
776
  fps: options.fps ?? DEFAULT_FPS,
777
+ maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
720
778
  scale: options.scale ?? DEFAULT_SCALE,
721
779
  quality: options.quality ?? DEFAULT_QUALITY,
780
+ iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
781
+ animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
722
782
  debug: options.debug ?? false
723
783
  };
724
784
  }
@@ -989,7 +1049,9 @@ async function runPipeline(options) {
989
1049
  }
990
1050
  ctx.emitProgress(100);
991
1051
  ctx.status = "ANALYZING";
992
- ctx.graph = await analyzeFrames(ctx);
1052
+ const { edges, animations } = await analyzeFrames(ctx);
1053
+ ctx.graph = edges;
1054
+ ctx.animations = animations;
993
1055
  ctx.status = "PRUNING";
994
1056
  const survivingIds = pruneByThresholdWithCap(
995
1057
  ctx.graph,
@@ -1022,6 +1084,15 @@ async function runPipeline(options) {
1022
1084
  prunedFramesCount: prunedFrames.length,
1023
1085
  outputFiles,
1024
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
+ },
1025
1096
  executionTimeMs: Date.now() - startTime
1026
1097
  };
1027
1098
  } catch (error) {