@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.
@@ -2,7 +2,7 @@
2
2
  import { parentPort, workerData } from "worker_threads";
3
3
 
4
4
  // src/core/orchestrator.ts
5
- import { randomUUID } from "crypto";
5
+ import { randomUUID as randomUUID2 } from "crypto";
6
6
 
7
7
  // src/utils/logger.ts
8
8
  import pc from "picocolors";
@@ -67,6 +67,8 @@ var PIXELDIFF_GAUSSIAN_KERNEL = 3;
67
67
  var PIXELDIFF_BINARY_THRESHOLD = 30;
68
68
  var PIXELDIFF_CONTOUR_MIN_AREA = 100;
69
69
  var PIXELDIFF_SAMPLE_SPACING = 8;
70
+ var DEFAULT_MAX_SEGMENT_DURATION = 300;
71
+ var DEFAULT_SEGMENT_CONCURRENCY = 2;
70
72
  function getTempWorkspaceDir(sessionId) {
71
73
  return join(TEMP_BASE_DIR, `${WORKSPACE_PREFIX}${sessionId}`);
72
74
  }
@@ -659,6 +661,23 @@ async function buildFrameList(framesDir, duration) {
659
661
  extractPath: join2(framesDir, file)
660
662
  }));
661
663
  }
664
+ async function extractFramesForRange(inputPath, outputDir, fps, scale, startTime, duration) {
665
+ const outputPattern = join2(outputDir, FRAME_FILENAME_PATTERN);
666
+ await execa(ffmpegPath, [
667
+ "-ss",
668
+ String(startTime),
669
+ "-i",
670
+ inputPath,
671
+ "-t",
672
+ String(duration),
673
+ "-vf",
674
+ `fps=${fps},scale=-1:${scale}`,
675
+ "-q:v",
676
+ "2",
677
+ outputPattern
678
+ ]);
679
+ return buildFrameList(outputDir, duration);
680
+ }
662
681
 
663
682
  // src/core/input-resolver.ts
664
683
  import { join as join4 } from "path";
@@ -721,6 +740,15 @@ async function finalizeOutput(ctx, selectedFrames) {
721
740
  await rename(stagingDir, outputPath);
722
741
  return outputFiles;
723
742
  }
743
+ async function createSegmentWorkspace(parentWorkspacePath, segmentIndex) {
744
+ const segmentPath = join3(
745
+ parentWorkspacePath,
746
+ "segments",
747
+ String(segmentIndex)
748
+ );
749
+ await ensureDir(join3(segmentPath, "frames"));
750
+ return segmentPath;
751
+ }
724
752
  async function cleanupWorkspace(workspacePath) {
725
753
  if (!workspacePath) return;
726
754
  try {
@@ -781,7 +809,9 @@ function resolveOptions(options2) {
781
809
  quality: options2.quality ?? DEFAULT_QUALITY,
782
810
  iouThreshold: options2.iouThreshold ?? IOU_THRESHOLD,
783
811
  animationThreshold: options2.animationThreshold ?? ANIMATION_FRAME_THRESHOLD,
784
- debug: options2.debug ?? false
812
+ debug: options2.debug ?? false,
813
+ maxSegmentDuration: options2.maxSegmentDuration ?? DEFAULT_MAX_SEGMENT_DURATION,
814
+ concurrency: options2.concurrency ?? DEFAULT_SEGMENT_CONCURRENCY
785
815
  };
786
816
  }
787
817
  async function resolveInput(options2, workspacePath) {
@@ -1033,13 +1063,361 @@ function pruneByThresholdWithCap(graph, frames, threshold, maxCount) {
1033
1063
  return pruneTo(syntheticEdges, survivingFrames, maxCount);
1034
1064
  }
1035
1065
 
1066
+ // src/core/segmenter.ts
1067
+ import { randomUUID } from "crypto";
1068
+ import { join as join5 } from "path";
1069
+
1070
+ // src/utils/concurrency.ts
1071
+ function concurrencyLimit(limit) {
1072
+ limit = Math.max(1, limit);
1073
+ let active = 0;
1074
+ const queue = [];
1075
+ return async (fn) => {
1076
+ while (active >= limit) {
1077
+ await new Promise((resolve2) => queue.push(resolve2));
1078
+ }
1079
+ active++;
1080
+ try {
1081
+ return await fn();
1082
+ } finally {
1083
+ active--;
1084
+ queue.shift()?.();
1085
+ }
1086
+ };
1087
+ }
1088
+
1089
+ // src/core/segmenter.ts
1090
+ function shouldSegment(resolvedOptions, originalOptions) {
1091
+ if (resolvedOptions.mode === "frames") return false;
1092
+ if (originalOptions.mode === "file") {
1093
+ if (originalOptions.inputPath.toLowerCase().endsWith(".gif")) return false;
1094
+ }
1095
+ return true;
1096
+ }
1097
+ function computeSegmentPlan(totalDuration, maxSegmentDuration, maxFrames, fps) {
1098
+ const effectiveFps = Math.max(0.5, Math.min(fps, maxFrames / totalDuration));
1099
+ if (totalDuration <= maxSegmentDuration) {
1100
+ return [
1101
+ {
1102
+ index: 0,
1103
+ startTime: 0,
1104
+ endTime: totalDuration,
1105
+ duration: totalDuration,
1106
+ allocatedFrames: Math.min(
1107
+ Math.ceil(effectiveFps * totalDuration),
1108
+ maxFrames
1109
+ ),
1110
+ effectiveFps,
1111
+ overlapBefore: 0,
1112
+ overlapAfter: 0,
1113
+ extractStartTime: 0,
1114
+ extractDuration: totalDuration
1115
+ }
1116
+ ];
1117
+ }
1118
+ const segmentCount = Math.ceil(totalDuration / maxSegmentDuration);
1119
+ const overlapTime = 1 / effectiveFps;
1120
+ const segments = [];
1121
+ for (let i = 0; i < segmentCount; i++) {
1122
+ const startTime = i * maxSegmentDuration;
1123
+ const endTime = Math.min((i + 1) * maxSegmentDuration, totalDuration);
1124
+ const duration = endTime - startTime;
1125
+ const overlapBefore = i > 0 ? 1 : 0;
1126
+ const overlapAfter = i < segmentCount - 1 ? 1 : 0;
1127
+ const extractStartTime = Math.max(
1128
+ 0,
1129
+ startTime - overlapBefore * overlapTime
1130
+ );
1131
+ const extractEndTime = Math.min(
1132
+ totalDuration,
1133
+ endTime + overlapAfter * overlapTime
1134
+ );
1135
+ const extractDuration = extractEndTime - extractStartTime;
1136
+ segments.push({
1137
+ index: i,
1138
+ startTime,
1139
+ endTime,
1140
+ duration,
1141
+ allocatedFrames: Math.ceil(effectiveFps * duration),
1142
+ effectiveFps,
1143
+ overlapBefore,
1144
+ overlapAfter,
1145
+ extractStartTime,
1146
+ extractDuration
1147
+ });
1148
+ }
1149
+ const totalAllocated = segments.reduce(
1150
+ (sum, s) => sum + s.allocatedFrames,
1151
+ 0
1152
+ );
1153
+ if (totalAllocated > maxFrames) {
1154
+ segments[segments.length - 1].allocatedFrames -= totalAllocated - maxFrames;
1155
+ }
1156
+ return segments;
1157
+ }
1158
+ function mergeSegmentFrames(segmentResults) {
1159
+ if (segmentResults.length === 0) {
1160
+ return { frames: [], edges: [], animations: [] };
1161
+ }
1162
+ const allFrames = [];
1163
+ for (const result of segmentResults) {
1164
+ for (const frame of result.frames) {
1165
+ allFrames.push({
1166
+ frame: {
1167
+ ...frame,
1168
+ // Use extractStartTime for timestamp correction (Section 18 note 1)
1169
+ timestamp: frame.timestamp + result.segment.extractStartTime
1170
+ },
1171
+ segmentIndex: result.segment.index,
1172
+ localId: frame.id
1173
+ });
1174
+ }
1175
+ }
1176
+ allFrames.sort((a, b) => a.frame.timestamp - b.frame.timestamp);
1177
+ const effectiveFps = segmentResults[0].segment.effectiveFps;
1178
+ const dupThreshold = 1 / (effectiveFps * 2);
1179
+ const uniqueFrames = [];
1180
+ for (const entry of allFrames) {
1181
+ if (uniqueFrames.length > 0) {
1182
+ const last = uniqueFrames[uniqueFrames.length - 1];
1183
+ if (Math.abs(entry.frame.timestamp - last.frame.timestamp) < dupThreshold) {
1184
+ continue;
1185
+ }
1186
+ }
1187
+ uniqueFrames.push(entry);
1188
+ }
1189
+ const globalIdMap = /* @__PURE__ */ new Map();
1190
+ const frames = uniqueFrames.map((entry, globalId) => {
1191
+ globalIdMap.set(`${entry.segmentIndex}:${entry.localId}`, globalId);
1192
+ return {
1193
+ id: globalId,
1194
+ timestamp: entry.frame.timestamp,
1195
+ extractPath: entry.frame.extractPath
1196
+ };
1197
+ });
1198
+ const edges = [];
1199
+ const edgeMap = /* @__PURE__ */ new Map();
1200
+ for (const result of segmentResults) {
1201
+ for (const edge of result.edges) {
1202
+ const newSourceId = globalIdMap.get(
1203
+ `${result.segment.index}:${edge.sourceId}`
1204
+ );
1205
+ const newTargetId = globalIdMap.get(
1206
+ `${result.segment.index}:${edge.targetId}`
1207
+ );
1208
+ if (newSourceId === void 0 || newTargetId === void 0) continue;
1209
+ const edgeKey = `${newSourceId}-${newTargetId}`;
1210
+ const existingIdx = edgeMap.get(edgeKey);
1211
+ if (existingIdx !== void 0) {
1212
+ if (edges[existingIdx].score < edge.score) {
1213
+ edges[existingIdx] = {
1214
+ sourceId: newSourceId,
1215
+ targetId: newTargetId,
1216
+ score: edge.score
1217
+ };
1218
+ }
1219
+ } else {
1220
+ edgeMap.set(edgeKey, edges.length);
1221
+ edges.push({
1222
+ sourceId: newSourceId,
1223
+ targetId: newTargetId,
1224
+ score: edge.score
1225
+ });
1226
+ }
1227
+ }
1228
+ }
1229
+ const animations = [];
1230
+ for (const result of segmentResults) {
1231
+ for (const anim of result.animations) {
1232
+ const newStartId = globalIdMap.get(
1233
+ `${result.segment.index}:${anim.startFrameId}`
1234
+ );
1235
+ const newEndId = globalIdMap.get(
1236
+ `${result.segment.index}:${anim.endFrameId}`
1237
+ );
1238
+ if (newStartId === void 0 || newEndId === void 0) continue;
1239
+ animations.push({
1240
+ ...anim,
1241
+ startFrameId: newStartId,
1242
+ endFrameId: newEndId
1243
+ });
1244
+ }
1245
+ }
1246
+ return { frames, edges, animations };
1247
+ }
1248
+ function buildSegmentContext(segment, frames, segmentWorkspacePath, resolvedOptions, onProgress) {
1249
+ return {
1250
+ options: {
1251
+ ...resolvedOptions,
1252
+ fps: segment.effectiveFps,
1253
+ maxFrames: segment.allocatedFrames
1254
+ },
1255
+ workspacePath: segmentWorkspacePath,
1256
+ frames,
1257
+ graph: [],
1258
+ status: "ANALYZING",
1259
+ emitProgress: onProgress
1260
+ };
1261
+ }
1262
+ async function processSegment(inputPath, segment, workspacePath, resolvedOptions, onProgress) {
1263
+ const framesDir = join5(workspacePath, "frames");
1264
+ const frames = await extractFramesForRange(
1265
+ inputPath,
1266
+ framesDir,
1267
+ segment.effectiveFps,
1268
+ resolvedOptions.scale,
1269
+ segment.extractStartTime,
1270
+ segment.extractDuration
1271
+ );
1272
+ if (frames.length < 2) {
1273
+ return { segment, frames, edges: [], animations: [] };
1274
+ }
1275
+ const ctx = buildSegmentContext(
1276
+ segment,
1277
+ frames,
1278
+ workspacePath,
1279
+ resolvedOptions,
1280
+ onProgress
1281
+ );
1282
+ const { edges, animations } = await analyzeFrames(ctx);
1283
+ return { segment, frames, edges, animations };
1284
+ }
1285
+ async function runSegmentedPipeline(options2, resolvedOptions) {
1286
+ const pipelineStart = Date.now();
1287
+ const sessionId = randomUUID();
1288
+ let mainWorkspace = "";
1289
+ try {
1290
+ mainWorkspace = await createWorkspace(sessionId);
1291
+ logger.debug(`Segmented pipeline: workspace at ${mainWorkspace}`);
1292
+ const { resolvedInputPath } = await resolveInput(options2, mainWorkspace);
1293
+ const inputPath = resolvedInputPath ?? resolvedOptions.inputPath;
1294
+ if (!inputPath) {
1295
+ throw new Error("No input path available for segmented pipeline");
1296
+ }
1297
+ const metadata = await getVideoMetadata(inputPath);
1298
+ const totalDuration = parseFloat(metadata.format?.duration ?? "0");
1299
+ if (totalDuration <= 0) {
1300
+ throw new Error(`Invalid video duration: ${totalDuration}`);
1301
+ }
1302
+ logger.debug(`Video duration: ${totalDuration}s`);
1303
+ const segments = computeSegmentPlan(
1304
+ totalDuration,
1305
+ resolvedOptions.maxSegmentDuration,
1306
+ resolvedOptions.maxFrames,
1307
+ resolvedOptions.fps
1308
+ );
1309
+ logger.debug(`Segment plan: ${segments.length} segments`);
1310
+ const limit = concurrencyLimit(resolvedOptions.concurrency);
1311
+ const segmentProgresses = new Array(segments.length).fill(0);
1312
+ const weights = segments.map((s) => s.duration / totalDuration);
1313
+ const emitOverallProgress = (phase) => {
1314
+ if (!options2.onProgress) return;
1315
+ const overall = weights.reduce(
1316
+ (sum, w, i) => sum + w * (segmentProgresses[i] ?? 0),
1317
+ 0
1318
+ );
1319
+ options2.onProgress(phase, Math.min(100, overall));
1320
+ };
1321
+ options2.onProgress?.("EXTRACTING", 0);
1322
+ const results = await Promise.all(
1323
+ segments.map(
1324
+ (segment) => limit(async () => {
1325
+ const segWorkspace = await createSegmentWorkspace(
1326
+ mainWorkspace,
1327
+ segment.index
1328
+ );
1329
+ const result = await processSegment(
1330
+ inputPath,
1331
+ segment,
1332
+ segWorkspace,
1333
+ resolvedOptions,
1334
+ (percent) => {
1335
+ segmentProgresses[segment.index] = percent;
1336
+ emitOverallProgress("ANALYZING");
1337
+ }
1338
+ );
1339
+ return result;
1340
+ })
1341
+ )
1342
+ );
1343
+ options2.onProgress?.("ANALYZING", 100);
1344
+ const { frames, edges, animations } = mergeSegmentFrames(results);
1345
+ logger.debug(
1346
+ `Merged: ${frames.length} frames, ${edges.length} edges, ${animations.length} animations`
1347
+ );
1348
+ options2.onProgress?.("PRUNING", 0);
1349
+ const survivingIds = pruneByThresholdWithCap(
1350
+ edges,
1351
+ frames,
1352
+ resolvedOptions.threshold,
1353
+ resolvedOptions.count
1354
+ );
1355
+ const prunedFrames = frames.filter((f) => survivingIds.has(f.id));
1356
+ options2.onProgress?.("PRUNING", 100);
1357
+ options2.onProgress?.("FINALIZING", 0);
1358
+ const ctx = {
1359
+ options: resolvedOptions,
1360
+ workspacePath: mainWorkspace,
1361
+ frames,
1362
+ graph: edges,
1363
+ animations,
1364
+ status: "FINALIZING",
1365
+ emitProgress: (percent) => options2.onProgress?.("FINALIZING", percent)
1366
+ };
1367
+ let outputFiles = [];
1368
+ let outputBuffers;
1369
+ if (resolvedOptions.mode === "buffer" || resolvedOptions.mode === "frames") {
1370
+ outputBuffers = await readFramesAsBuffers(
1371
+ prunedFrames,
1372
+ resolvedOptions.quality
1373
+ );
1374
+ } else {
1375
+ outputFiles = await finalizeOutput(ctx, prunedFrames);
1376
+ }
1377
+ options2.onProgress?.("FINALIZING", 100);
1378
+ logger.success(
1379
+ `Segmented pipeline: ${prunedFrames.length} scenes from ${frames.length} frames (${segments.length} segments)`
1380
+ );
1381
+ return {
1382
+ success: true,
1383
+ originalFramesCount: frames.length,
1384
+ prunedFramesCount: prunedFrames.length,
1385
+ outputFiles,
1386
+ outputBuffers,
1387
+ animations,
1388
+ video: {
1389
+ originalDurationMs: totalDuration * 1e3,
1390
+ fps: resolvedOptions.fps,
1391
+ resolution: {
1392
+ width: resolvedOptions.scale,
1393
+ height: Math.round(resolvedOptions.scale * 9 / 16)
1394
+ }
1395
+ },
1396
+ executionTimeMs: Date.now() - pipelineStart
1397
+ };
1398
+ } catch (error) {
1399
+ const err = error instanceof Error ? error : new Error(String(error));
1400
+ logger.error(`Segmented pipeline failed: ${err.message}`);
1401
+ throw err;
1402
+ } finally {
1403
+ if (!resolvedOptions.debug) {
1404
+ await cleanupWorkspace(mainWorkspace);
1405
+ } else {
1406
+ logger.debug(`Debug mode: workspace preserved at ${mainWorkspace}`);
1407
+ }
1408
+ }
1409
+ }
1410
+
1036
1411
  // src/core/orchestrator.ts
1037
1412
  async function runPipeline(options2) {
1038
- const startTime = Date.now();
1039
- const sessionId = randomUUID();
1040
1413
  const debug = options2.debug ?? false;
1041
1414
  if (debug) setDebugMode(true);
1042
1415
  const resolvedOptions = resolveOptions(options2);
1416
+ if (shouldSegment(resolvedOptions, options2)) {
1417
+ return runSegmentedPipeline(options2, resolvedOptions);
1418
+ }
1419
+ const startTime = Date.now();
1420
+ const sessionId = randomUUID2();
1043
1421
  const ctx = {
1044
1422
  options: resolvedOptions,
1045
1423
  workspacePath: "",
@@ -21,6 +21,8 @@ export interface SieveOptionsBase {
21
21
  animationThreshold?: number;
22
22
  debug?: boolean;
23
23
  onProgress?: (phase: ProgressPhase, percent: number) => void;
24
+ maxSegmentDuration?: number;
25
+ concurrency?: number;
24
26
  }
25
27
  export type SieveOptions = SieveOptionsBase & SieveInput;
26
28
  export interface ResolvedOptions {
@@ -37,6 +39,8 @@ export interface ResolvedOptions {
37
39
  iouThreshold: number;
38
40
  animationThreshold: number;
39
41
  debug: boolean;
42
+ maxSegmentDuration: number;
43
+ concurrency: number;
40
44
  }
41
45
  export interface SieveResult {
42
46
  success: boolean;
@@ -105,3 +109,21 @@ export interface AnalysisResult {
105
109
  edges: ScoreEdge[];
106
110
  animations: AnimationMetadata[];
107
111
  }
112
+ export interface SegmentPlan {
113
+ index: number;
114
+ startTime: number;
115
+ endTime: number;
116
+ duration: number;
117
+ allocatedFrames: number;
118
+ effectiveFps: number;
119
+ overlapBefore: number;
120
+ overlapAfter: number;
121
+ extractStartTime: number;
122
+ extractDuration: number;
123
+ }
124
+ export interface SegmentResult {
125
+ segment: SegmentPlan;
126
+ frames: FrameNode[];
127
+ edges: ScoreEdge[];
128
+ animations: AnimationMetadata[];
129
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Creates a concurrency limiter that runs at most `limit` tasks in parallel.
3
+ * Lightweight replacement for p-limit to avoid external dependency.
4
+ */
5
+ export declare function concurrencyLimit(limit: number): <T>(fn: () => Promise<T>) => Promise<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumy-pack/scene-sieve",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "CLI tool for extracting key frames from video and GIF files",
5
5
  "keywords": [
6
6
  "cli",