@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/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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
317
+ if (region.consecutiveCount >= this.animationThreshold) {
290
318
  const iou = computeIoU(box, region.box);
291
- if (iou > IOU_THRESHOLD) {
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
- logger.debug(`Computed ${edges.length} score edges`);
533
- return edges;
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 allExtensions = [
589
- ...SUPPORTED_VIDEO_EXTENSIONS,
590
- ...SUPPORTED_GIF_EXTENSIONS
591
- ];
592
- if (!isSupportedFile(inputPath, allExtensions)) {
593
- throw new Error(`Unsupported file format: ${inputPath}`);
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
- logger.debug(`Extracting frames from: ${inputPath}`);
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
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
645
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
605
646
  );
606
647
  }
607
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
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, "frame_%06d.jpg");
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, inputPath);
670
+ return buildFrameList(outputDir, duration);
624
671
  }
625
- async function getVideoDuration(inputPath) {
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
- const metadata = JSON.parse(stdout);
635
- return parseFloat(metadata.format?.duration ?? "0");
682
+ return JSON.parse(stdout);
636
683
  }
637
- async function buildFrameList(framesDir, inputPath) {
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
679
- const destPath = (0, import_node_path4.join)(stagingDir, destName);
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, destName));
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
- ctx.graph = await analyzeFrames(ctx);
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 > IOU_THRESHOLD && bestRegionIdx !== -1) {
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 >= ANIMATION_FRAME_THRESHOLD) {
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
- this.regions = this.regions.filter((r) => r.weight > 0.01);
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 >= ANIMATION_FRAME_THRESHOLD) {
277
+ if (region.consecutiveCount >= this.animationThreshold) {
250
278
  const iou = computeIoU(box, region.box);
251
- if (iou > IOU_THRESHOLD) {
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
- logger.debug(`Computed ${edges.length} score edges`);
493
- return edges;
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 allExtensions = [
549
- ...SUPPORTED_VIDEO_EXTENSIONS,
550
- ...SUPPORTED_GIF_EXTENSIONS
551
- ];
552
- if (!isSupportedFile(inputPath, allExtensions)) {
553
- throw new Error(`Unsupported file format: ${inputPath}`);
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
- logger.debug(`Extracting frames from: ${inputPath}`);
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
- `Duration: ${duration.toFixed(1)}s, FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
605
+ `FPS: ${fps} \u2192 effective: ${effectiveFps.toFixed(2)} (maxFrames: ${maxFrames})`
565
606
  );
566
607
  }
567
- const frames = await extractByFps(inputPath, framesDir, effectiveFps, scale);
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, "frame_%06d.jpg");
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, inputPath);
630
+ return buildFrameList(outputDir, duration);
584
631
  }
585
- async function getVideoDuration(inputPath) {
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
- const metadata = JSON.parse(stdout);
595
- return parseFloat(metadata.format?.duration ?? "0");
642
+ return JSON.parse(stdout);
596
643
  }
597
- async function buildFrameList(framesDir, inputPath) {
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 destName = `scene_${String(i + 1).padStart(3, "0")}.jpg`;
639
- const destPath = join3(stagingDir, destName);
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, destName));
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
- ctx.graph = await analyzeFrames(ctx);
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) {