@lumy-pack/scene-sieve 0.0.8 → 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";
@@ -64,6 +64,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
64
64
  var PIXELDIFF_BINARY_THRESHOLD = 30;
65
65
  var PIXELDIFF_CONTOUR_MIN_AREA = 100;
66
66
  var PIXELDIFF_SAMPLE_SPACING = 8;
67
+ var DEFAULT_MAX_SEGMENT_DURATION = 300;
68
+ var DEFAULT_SEGMENT_CONCURRENCY = 2;
67
69
  function getTempWorkspaceDir(sessionId) {
68
70
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
69
71
  }
@@ -656,6 +658,23 @@ async function buildFrameList(framesDir, duration) {
656
658
  extractPath: join2(framesDir, file)
657
659
  }));
658
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
+ }
659
678
 
660
679
  // src/core/input-resolver.ts
661
680
  import { join as join4 } from "path";
@@ -718,6 +737,15 @@ async function finalizeOutput(ctx, selectedFrames) {
718
737
  await rename(stagingDir, outputPath);
719
738
  return outputFiles;
720
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
+ }
721
749
  async function cleanupWorkspace(workspacePath) {
722
750
  if (!workspacePath) return;
723
751
  try {
@@ -778,7 +806,9 @@ function resolveOptions(options) {
778
806
  quality: options.quality ?? DEFAULT_QUALITY,
779
807
  iouThreshold: options.iouThreshold ?? IOU_THRESHOLD,
780
808
  animationThreshold: options.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
781
- debug: options.debug ?? false
809
+ debug: options.debug ?? false,
810
+ maxSegmentDuration: options.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
811
+ concurrency: options.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
782
812
  };
783
813
  }
784
814
  async function resolveInput(options, workspacePath) {
@@ -1030,13 +1060,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1030
1060
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1031
1061
  }
1032
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
+
1033
1408
  // src/core/orchestrator.ts
1034
1409
  async function runPipeline(options) {
1035
- const startTime = Date.now();
1036
- const sessionId = randomUUID();
1037
1410
  const debug = options.debug ?? false;
1038
1411
  if (debug) setDebugMode(true);
1039
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();
1040
1418
  const ctx = {
1041
1419
  options: resolvedOptions,
1042
1420
  workspacePath: "",