@lumy-pack/scene-sieve 0.0.7 → 0.0.9

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
@@ -1,5 +1,5 @@
1
1
  // src/core/orchestrator.ts
2
- import { randomUUID } from "crypto";
2
+ import { randomUUID as randomUUID2 } from "crypto";
3
3
 
4
4
  // src/utils/logger.ts
5
5
  import pc from "picocolors";
@@ -45,7 +45,10 @@ var DEFAULT_FPS = 5;
45
45
  var DEFAULT_SCALE = 720;
46
46
  var DEFAULT_QUALITY = 80;
47
47
  var DEFAULT_MAX_FRAMES = 300;
48
- var NORMALIZATION_PERCENTILE = 0.9;
48
+ var NORMALIZATION_LOGISTIC_K = 3;
49
+ var NORMALIZATION_ALPHA = 0.4;
50
+ var NORMALIZATION_MAD_COEFFICIENT = 1.4826;
51
+ var NORMALIZATION_MIN_SAMPLE_SIZE = 10;
49
52
  var WORKSPACE_PREFIX = `${APP_NAME}-`;
50
53
  var TEMP_BASE_DIR = tmpdir();
51
54
  var FRAME_OUTPUT_EXTENSION = ".jpg";
@@ -61,6 +64,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
61
64
  var PIXELDIFF_BINARY_THRESHOLD = 30;
62
65
  var PIXELDIFF_CONTOUR_MIN_AREA = 100;
63
66
  var PIXELDIFF_SAMPLE_SPACING = 8;
67
+ var DEFAULT_MAX_SEGMENT_DURATION = 300;
68
+ var DEFAULT_SEGMENT_CONCURRENCY = 2;
64
69
  function getTempWorkspaceDir(sessionId) {
65
70
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
66
71
  }
@@ -653,6 +658,23 @@ async function buildFrameList(framesDir, duration) {
653
658
  extractPath: join2(framesDir, file)
654
659
  }));
655
660
  }
661
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
662
+ const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
663
+ await execa(ffmpegPath, [
664
+ "-ss",
665
+ String(startTime),
666
+ "-i",
667
+ inputPath,
668
+ "-t",
669
+ String(duration),
670
+ "-vf",
671
+ `fps=${fps},scale=-1:${scale}`,
672
+ "-q:v",
673
+ "2",
674
+ outputPattern
675
+ ]);
676
+ return buildFrameList(outputDir, duration);
677
+ }
656
678
 
657
679
  // src/core/input-resolver.ts
658
680
  import { join as join4 } from "path";
@@ -715,6 +737,15 @@ async function finalizeOutput(ctx, selectedFrames) {
715
737
  await rename(stagingDir, outputPath);
716
738
  return outputFiles;
717
739
  }
740
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
741
+ const segmentPath = join3(
742
+ parentWorkspacePath,
743
+ "segments",
744
+ String(segmentIndex)
745
+ );
746
+ await ensureDir(join3(segmentPath, "frames"));
747
+ return segmentPath;
748
+ }
718
749
  async function cleanupWorkspace(workspacePath) {
719
750
  if (!workspacePath) return;
720
751
  try {
@@ -775,7 +806,9 @@ function resolveOptions(options) {
775
806
  quality: options.quality ?? DEFAULT_QUALITY,
776
807
  iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
777
808
  animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
778
- debug: options.debug ?? false
809
+ debug: options.debug ?? false,
810
+ maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
811
+ concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
779
812
  };
780
813
  }
781
814
  async function resolveInput(options, workspacePath) {
@@ -799,6 +832,43 @@ async function resolveInput(options, workspacePath) {
799
832
  throw new Error(`Unsupported input mode: ${options.mode}`);
800
833
  }
801
834
 
835
+ // src/utils/math.ts
836
+ function normalizeScores(items) {
837
+ if (items.length === 0) return [];
838
+ const safeScores = items.map(
839
+ (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
840
+ );
841
+ const positiveScores = safeScores.filter((s) => s > 0);
842
+ if (positiveScores.length === 0) return safeScores;
843
+ const sorted = [...positiveScores].sort((a, b) => a - b);
844
+ if (positiveScores.length <= NORMALIZATION_MIN_SAMPLE_SIZE) {
845
+ const min = sorted[0];
846
+ const max = sorted[sorted.length - 1];
847
+ if (max === min) return safeScores.map((s) => s > 0 ? 1 : 0);
848
+ return safeScores.map(
849
+ (s) => s <= 0 ? 0 : Math.max(0, Math.min((s - min) / (max - min), 1))
850
+ );
851
+ }
852
+ const median = sorted[Math.floor(sorted.length / 2)];
853
+ const absoluteDiffs = positiveScores.map((v) => Math.abs(v - median));
854
+ const mad = [...absoluteDiffs].sort((a, b) => a - b)[Math.floor(absoluteDiffs.length / 2)];
855
+ const scale = mad === 0 ? median : mad * NORMALIZATION_MAD_COEFFICIENT;
856
+ const logisticZ = safeScores.map((s) => {
857
+ if (s <= 0) return 0;
858
+ if (scale === 0) return 1;
859
+ const z = (s - median) / scale;
860
+ return 1 / (1 + Math.exp(-NORMALIZATION_LOGISTIC_K * z));
861
+ });
862
+ const cdf = safeScores.map((s) => {
863
+ if (s <= 0) return 0;
864
+ const rank = sorted.findIndex((v) => v >= s);
865
+ return rank / sorted.length;
866
+ });
867
+ return logisticZ.map(
868
+ (z, i) => z * (1 - NORMALIZATION_ALPHA) + cdf[i] * NORMALIZATION_ALPHA
869
+ );
870
+ }
871
+
802
872
  // src/utils/min-heap.ts
803
873
  var MinHeap = class {
804
874
  h = [];
@@ -893,20 +963,6 @@ function pruneTo(graph, frames, targetCount) {
893
963
  }
894
964
  return surviving;
895
965
  }
896
- function normalizeScores(graph) {
897
- if (graph.length === 0) return [];
898
- const safeScores = graph.map(
899
- (e) => Number.isFinite(e.score) && e.score > 0 ? e.score : 0
900
- );
901
- const sorted = [...safeScores].filter((s) => s > 0).sort((a, b) => a - b);
902
- if (sorted.length === 0) return safeScores;
903
- const pIdx = Math.min(
904
- Math.floor(sorted.length * NORMALIZATION_PERCENTILE),
905
- sorted.length - 1
906
- );
907
- const refScore = sorted[pIdx];
908
- return safeScores.map((s) => Math.min(s / refScore, 1));
909
- }
910
966
  function suppressConsecutiveRuns(graph, passingIndices, normalizedScores) {
911
967
  const result = /* @__PURE__ */ new Set();
912
968
  let runStart = 0;
@@ -1004,13 +1060,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1004
1060
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1005
1061
  }
1006
1062
 
1063
+ // src/core/segmenter.ts
1064
+ import { randomUUID } from "crypto";
1065
+ import { join as join5 } from "path";
1066
+
1067
+ // src/utils/concurrency.ts
1068
+ function concurrencyLimit(limit) {
1069
+ limit = Math.max(1, limit);
1070
+ let active = 0;
1071
+ const queue = [];
1072
+ return async (fn) => {
1073
+ while (active >= limit) {
1074
+ await new Promise((resolve2) => queue.push(resolve2));
1075
+ }
1076
+ active++;
1077
+ try {
1078
+ return await fn();
1079
+ } finally {
1080
+ active--;
1081
+ queue.shift()?.();
1082
+ }
1083
+ };
1084
+ }
1085
+
1086
+ // src/core/segmenter.ts
1087
+ function shouldSegment(resolvedOptions, originalOptions) {
1088
+ if (resolvedOptions.mode === "frames") return false;
1089
+ if (originalOptions.mode === "file") {
1090
+ if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
1091
+ }
1092
+ return true;
1093
+ }
1094
+ function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1095
+ const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
1096
+ if (totalDuration <= maxSegmentDuration) {
1097
+ return [
1098
+ {
1099
+ index: 0,
1100
+ startTime: 0,
1101
+ endTime: totalDuration,
1102
+ duration: totalDuration,
1103
+ allocatedFrames: Math.min(
1104
+ Math.ceil(effectiveFps * totalDuration),
1105
+ maxFrames
1106
+ ),
1107
+ effectiveFps,
1108
+ overlapBefore: 0,
1109
+ overlapAfter: 0,
1110
+ extractStartTime: 0,
1111
+ extractDuration: totalDuration
1112
+ }
1113
+ ];
1114
+ }
1115
+ const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1116
+ const overlapTime = 1 / effectiveFps;
1117
+ const segments = [];
1118
+ for (let i = 0; i < segmentCount; i++) {
1119
+ const startTime = i * maxSegmentDuration;
1120
+ const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1121
+ const duration = endTime - startTime;
1122
+ const overlapBefore = i > 0 ? 1 : 0;
1123
+ const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1124
+ const extractStartTime = Math.max(
1125
+ 0,
1126
+ startTime - overlapBefore * overlapTime
1127
+ );
1128
+ const extractEndTime = Math.min(
1129
+ totalDuration,
1130
+ endTime + overlapAfter * overlapTime
1131
+ );
1132
+ const extractDuration = extractEndTime - extractStartTime;
1133
+ segments.push({
1134
+ index: i,
1135
+ startTime,
1136
+ endTime,
1137
+ duration,
1138
+ allocatedFrames: Math.ceil(effectiveFps * duration),
1139
+ effectiveFps,
1140
+ overlapBefore,
1141
+ overlapAfter,
1142
+ extractStartTime,
1143
+ extractDuration
1144
+ });
1145
+ }
1146
+ const totalAllocated = segments.reduce(
1147
+ (sum, s) => sum + s.allocatedFrames,
1148
+ 0
1149
+ );
1150
+ if (totalAllocated > maxFrames) {
1151
+ segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1152
+ }
1153
+ return segments;
1154
+ }
1155
+ function mergeSegmentFrames(segmentResults) {
1156
+ if (segmentResults.length === 0) {
1157
+ return { frames: [], edges: [], animations: [] };
1158
+ }
1159
+ const allFrames = [];
1160
+ for (const result of segmentResults) {
1161
+ for (const frame of result.frames) {
1162
+ allFrames.push({
1163
+ frame: {
1164
+ ...frame,
1165
+ // Use extractStartTime for timestamp correction (Section 18 note 1)
1166
+ timestamp: frame.timestamp + result.segment.extractStartTime
1167
+ },
1168
+ segmentIndex: result.segment.index,
1169
+ localId: frame.id
1170
+ });
1171
+ }
1172
+ }
1173
+ allFrames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1174
+ const effectiveFps = segmentResults[0].segment.effectiveFps;
1175
+ const dupThreshold = 1 / (effectiveFps * 2);
1176
+ const uniqueFrames = [];
1177
+ for (const entry of allFrames) {
1178
+ if (uniqueFrames.length > 0) {
1179
+ const last = uniqueFrames[uniqueFrames.length - 1];
1180
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1181
+ continue;
1182
+ }
1183
+ }
1184
+ uniqueFrames.push(entry);
1185
+ }
1186
+ const globalIdMap = /* @__PURE__ */ new Map();
1187
+ const frames = uniqueFrames.map((entry, globalId) => {
1188
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1189
+ return {
1190
+ id: globalId,
1191
+ timestamp: entry.frame.timestamp,
1192
+ extractPath: entry.frame.extractPath
1193
+ };
1194
+ });
1195
+ const edges = [];
1196
+ const edgeMap = /* @__PURE__ */ new Map();
1197
+ for (const result of segmentResults) {
1198
+ for (const edge of result.edges) {
1199
+ const newSourceId = globalIdMap.get(
1200
+ `${result.segment.index}:${edge.sourceId}`
1201
+ );
1202
+ const newTargetId = globalIdMap.get(
1203
+ `${result.segment.index}:${edge.targetId}`
1204
+ );
1205
+ if (newSourceId === void 0 || newTargetId === void 0) continue;
1206
+ const edgeKey = `${newSourceId}-${newTargetId}`;
1207
+ const existingIdx = edgeMap.get(edgeKey);
1208
+ if (existingIdx !== void 0) {
1209
+ if (edges[existingIdx].score < edge.score) {
1210
+ edges[existingIdx] = {
1211
+ sourceId: newSourceId,
1212
+ targetId: newTargetId,
1213
+ score: edge.score
1214
+ };
1215
+ }
1216
+ } else {
1217
+ edgeMap.set(edgeKey, edges.length);
1218
+ edges.push({
1219
+ sourceId: newSourceId,
1220
+ targetId: newTargetId,
1221
+ score: edge.score
1222
+ });
1223
+ }
1224
+ }
1225
+ }
1226
+ const animations = [];
1227
+ for (const result of segmentResults) {
1228
+ for (const anim of result.animations) {
1229
+ const newStartId = globalIdMap.get(
1230
+ `${result.segment.index}:${anim.startFrameId}`
1231
+ );
1232
+ const newEndId = globalIdMap.get(
1233
+ `${result.segment.index}:${anim.endFrameId}`
1234
+ );
1235
+ if (newStartId === void 0 || newEndId === void 0) continue;
1236
+ animations.push({
1237
+ ...anim,
1238
+ startFrameId: newStartId,
1239
+ endFrameId: newEndId
1240
+ });
1241
+ }
1242
+ }
1243
+ return { frames, edges, animations };
1244
+ }
1245
+ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
1246
+ return {
1247
+ options: {
1248
+ ...resolvedOptions,
1249
+ fps: segment.effectiveFps,
1250
+ maxFrames: segment.allocatedFrames
1251
+ },
1252
+ workspacePath: segmentWorkspacePath,
1253
+ frames,
1254
+ graph: [],
1255
+ status: "ANALYZING",
1256
+ emitProgress: onProgress
1257
+ };
1258
+ }
1259
+ async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1260
+ const framesDir = join5(workspacePath, "frames");
1261
+ const frames = await extractFramesForRange(
1262
+ inputPath,
1263
+ framesDir,
1264
+ segment.effectiveFps,
1265
+ resolvedOptions.scale,
1266
+ segment.extractStartTime,
1267
+ segment.extractDuration
1268
+ );
1269
+ if (frames.length < 2) {
1270
+ return { segment, frames, edges: [], animations: [] };
1271
+ }
1272
+ const ctx = buildSegmentContext(
1273
+ segment,
1274
+ frames,
1275
+ workspacePath,
1276
+ resolvedOptions,
1277
+ onProgress
1278
+ );
1279
+ const { edges, animations } = await analyzeFrames(ctx);
1280
+ return { segment, frames, edges, animations };
1281
+ }
1282
+ async function runSegmentedPipeline(options, resolvedOptions) {
1283
+ const pipelineStart = Date.now();
1284
+ const sessionId = randomUUID();
1285
+ let mainWorkspace = "";
1286
+ try {
1287
+ mainWorkspace = await createWorkspace(sessionId);
1288
+ logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
1289
+ const { resolvedInputPath } = await resolveInput(options, mainWorkspace);
1290
+ const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
1291
+ if (!inputPath) {
1292
+ throw new Error("No input path available for segmented pipeline");
1293
+ }
1294
+ const metadata = await getVideoMetadata(inputPath);
1295
+ const totalDuration = parseFloat(metadata.format?.duration ?? "0");
1296
+ if (totalDuration <= 0) {
1297
+ throw new Error(`Invalid video duration: ${totalDuration}`);
1298
+ }
1299
+ logger.debug(`Video duration: ${totalDuration}s`);
1300
+ const segments = computeSegmentPlan(
1301
+ totalDuration,
1302
+ resolvedOptions.maxSegmentDuration,
1303
+ resolvedOptions.maxFrames,
1304
+ resolvedOptions.fps
1305
+ );
1306
+ logger.debug(`Segment plan: ${segments.length} segments`);
1307
+ const limit = concurrencyLimit(resolvedOptions.concurrency);
1308
+ const segmentProgresses = new Array(segments.length).fill(0);
1309
+ const weights = segments.map((s) => s.duration / totalDuration);
1310
+ const emitOverallProgress = (phase) => {
1311
+ if (!options.onProgress) return;
1312
+ const overall = weights.reduce(
1313
+ (sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
1314
+ 0
1315
+ );
1316
+ options.onProgress(phase, Math.min(100, overall));
1317
+ };
1318
+ options.onProgress?.("EXTRACTING", 0);
1319
+ const results = await Promise.all(
1320
+ segments.map(
1321
+ (segment) => limit(async () => {
1322
+ const segWorkspace = await createSegmentWorkspace(
1323
+ mainWorkspace,
1324
+ segment.index
1325
+ );
1326
+ const result = await processSegment(
1327
+ inputPath,
1328
+ segment,
1329
+ segWorkspace,
1330
+ resolvedOptions,
1331
+ (percent) => {
1332
+ segmentProgresses[segment.index] = percent;
1333
+ emitOverallProgress("ANALYZING");
1334
+ }
1335
+ );
1336
+ return result;
1337
+ })
1338
+ )
1339
+ );
1340
+ options.onProgress?.("ANALYZING", 100);
1341
+ const { frames, edges, animations } = mergeSegmentFrames(results);
1342
+ logger.debug(
1343
+ `Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
1344
+ );
1345
+ options.onProgress?.("PRUNING", 0);
1346
+ const survivingIds = pruneByThresholdWithCap(
1347
+ edges,
1348
+ frames,
1349
+ resolvedOptions.threshold,
1350
+ resolvedOptions.count
1351
+ );
1352
+ const prunedFrames = frames.filter((f) => survivingIds.has(f.id));
1353
+ options.onProgress?.("PRUNING", 100);
1354
+ options.onProgress?.("FINALIZING", 0);
1355
+ const ctx = {
1356
+ options: resolvedOptions,
1357
+ workspacePath: mainWorkspace,
1358
+ frames,
1359
+ graph: edges,
1360
+ animations,
1361
+ status: "FINALIZING",
1362
+ emitProgress: (percent) => options.onProgress?.("FINALIZING", percent)
1363
+ };
1364
+ let outputFiles = [];
1365
+ let outputBuffers;
1366
+ if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1367
+ outputBuffers = await readFramesAsBuffers(
1368
+ prunedFrames,
1369
+ resolvedOptions.quality
1370
+ );
1371
+ } else {
1372
+ outputFiles = await finalizeOutput(ctx, prunedFrames);
1373
+ }
1374
+ options.onProgress?.("FINALIZING", 100);
1375
+ logger.success(
1376
+ `Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
1377
+ );
1378
+ return {
1379
+ success: true,
1380
+ originalFramesCount: frames.length,
1381
+ prunedFramesCount: prunedFrames.length,
1382
+ outputFiles,
1383
+ outputBuffers,
1384
+ animations,
1385
+ video: {
1386
+ originalDurationMs: totalDuration * 1e3,
1387
+ fps: resolvedOptions.fps,
1388
+ resolution: {
1389
+ width: resolvedOptions.scale,
1390
+ height: Math.round(resolvedOptions.scale * 9 / 16)
1391
+ }
1392
+ },
1393
+ executionTimeMs: Date.now() - pipelineStart
1394
+ };
1395
+ } catch (error) {
1396
+ const err = error instanceof Error ? error : new Error(String(error));
1397
+ logger.error(`Segmented pipeline failed: ${err.message}`);
1398
+ throw err;
1399
+ } finally {
1400
+ if (!resolvedOptions.debug) {
1401
+ await cleanupWorkspace(mainWorkspace);
1402
+ } else {
1403
+ logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1404
+ }
1405
+ }
1406
+ }
1407
+
1007
1408
  // src/core/orchestrator.ts
1008
1409
  async function runPipeline(options) {
1009
- const startTime = Date.now();
1010
- const sessionId = randomUUID();
1011
1410
  const debug = options.debug ?? false;
1012
1411
  if (debug) setDebugMode(true);
1013
1412
  const resolvedOptions = resolveOptions(options);
1413
+ if (shouldSegment(resolvedOptions, options)) {
1414
+ return runSegmentedPipeline(options, resolvedOptions);
1415
+ }
1416
+ const startTime = Date.now();
1417
+ const sessionId = randomUUID2();
1014
1418
  const ctx = {
1015
1419
  options: resolvedOptions,
1016
1420
  workspacePath: "",