@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 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
- logger.debug(`Computed ${edges.length} score edges`);
454
- return edges;
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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
560
+ if (region.consecutiveCount >= this.animationThreshold) {
518
561
  const iou = computeIoU(box, region.box);
519
- if (iou > IOU_THRESHOLD) {
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, "frame_%06d.jpg");
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
681
- const destPath = join3(stagingDir, destName);
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, destName));
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
- ctx.graph = await analyzeFrames(ctx);
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", "5").option(
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
- "300"
1408
- ).option("-s, --scale <number>", "Scale size for vision analysis", "720").option("-q, --quality <number>", "JPEG output quality 1-100", "80").option("--debug", "Enable debug mode (preserve temp workspace)").action(async (input, opts) => {
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
  );
@@ -8,6 +8,8 @@ export interface SieveViewProps {
8
8
  maxFrames: number;
9
9
  scale: number;
10
10
  quality: number;
11
+ iouThreshold?: number;
12
+ animationThreshold?: number;
11
13
  debug: boolean;
12
14
  }
13
15
  export declare const SieveView: React.FC<SieveViewProps>;
@@ -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 {};
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 > 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
@@ -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, "frame_%06d.jpg");
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
679
- 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);
680
726
  await (0, import_sharp2.default)(frame.extractPath).jpeg({ quality, mozjpeg: true }).toFile(destPath);
681
- 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
+ });
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
- ctx.graph = await analyzeFrames(ctx);
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 > 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
@@ -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, "frame_%06d.jpg");
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
639
- const destPath = join3(stagingDir, destName);
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, 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
+ });
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
- ctx.graph = await analyzeFrames(ctx);
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) {
@@ -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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
288
+ if (region.consecutiveCount >= this.animationThreshold) {
253
289
  const iou = computeIoU(box, region.box);
254
- if (iou > IOU_THRESHOLD) {
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
- logger.debug(`Computed ${edges.length} score edges`);
496
- return edges;
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, "frame_%06d.jpg");
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
642
- const destPath = join3(stagingDir, destName);
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, destName));
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
- ctx.graph = await analyzeFrames(ctx);
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) {
@@ -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
+ }
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.6",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",